# TagInsight Notifications (Phase 1) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship an in-app notification system across the TagInsight multi-repo stack. Phase 1 delivers persistent notifications with polling-based real-time, three event sources (GTM Monitor new version, QA audit finished, QA audit failed), a bell + drawer in the global nav, a `/notifications` command center dashboard, and per-user / per-project preferences. No dependency on Reverb or Laravel 11.

**Architecture:** `commons/` package gains shared Eloquent models + TypeScript types. Origin APIs (saas-tag-management-api, saas-qa-datalayer-api) emit notification events by dispatching a queued job. Core-api owns the queue worker, the REST API for the inbox + preferences, and a daily retention sweep. Frontend polls core-api every 30s (plus on focus and on drawer open), displays a bell badge, drawer, dashboard, and preferences page.

**Tech Stack:** PHP 8.1+ / Laravel 10 (backends), MySQL (shared DB), Composer package `commons` (shared models), Vue 3 + TypeScript + Pinia + Vue Router (frontend), PHPUnit (backend tests), Vitest (frontend tests).

**Cross-repo dependency order:**

1. **Phase A** ships first: `commons` package version bump with new models + migrations published to the shared DB via core-api. Other backends must require the new `commons` version before they can use the new models.
2. **Phase B** lands in saas-core-api: REST API, queue job, retention command.
3. **Phase C** lands in parallel in saas-qa-datalayer-api and saas-tag-management-api: event emitters that dispatch the queue job from Phase B.
4. **Phase D** lands in saas-front-end: types from commons, Pinia store, components, views.

**Repo paths (absolute):**

- `commons/` = `/Users/yoanyahemdi/Projects/taginsight/saas_production/commons`
- `saas-core-api/` = `/Users/yoanyahemdi/Projects/taginsight/saas_production/saas-core-api`
- `saas-qa-datalayer-api/` = `/Users/yoanyahemdi/Projects/taginsight/saas_production/saas-qa-datalayer-api`
- `saas-tag-management-api/` = `/Users/yoanyahemdi/Projects/taginsight/saas_production/saas-tag-management-api`
- `saas-front-end/` = `/Users/yoanyahemdi/Projects/taginsight/saas_production/saas-front-end`

**Spec:** `docs/superpowers/specs/2026-05-21-notifications-design.md` (same repo as this plan).

**Branch naming:** create `feature/notifications-phase1` in each repo, target `dev`, request review from @nyfen + @joel.

---

## Pre-flight (no code, do these first)

- [ ] **PF-1: Confirm cross-API queue mechanism.** Before starting Phase B, verify with @joel or by reading existing code whether origin APIs can dispatch a Laravel job whose class lives in core-api (via shared queue + autoloader through `commons`), or whether the dispatch needs to be a queue-message + a core-api worker listening on a named queue. Update Phase C tasks if the latter.

Run:
```bash
grep -rn "dispatch(new " /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-qa-datalayer-api/app | head -20
grep -rn "Bus::dispatch\|queue:work" /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-core-api | head -20
```

If cross-repo dispatch works today, no plan change. If it does not, modify Phase C tasks to dispatch a simple `Notifications` queue message via Redis or DB queue, and add a Task B-X to register a listener in core-api that consumes that named queue.

- [ ] **PF-2: Confirm active-client source on backend.** Read `saas-core-api/app/Http/Middleware/` and `saas-core-api/routes/api.php` to see how the active client is passed today (request header, session, query param). Note the answer here, since `GET /notifications` defaults to active client.

- [ ] **PF-3: Confirm `commons` versioning workflow.** Check how `commons` is consumed by backends today (path repo, packagist, git tag). The plan assumes a version bump + `composer update commons` in each backend. Confirm the exact mechanism in `commons/composer.json` and at least one backend's `composer.json`.

Run:
```bash
cat /Users/yoanyahemdi/Projects/taginsight/saas_production/commons/composer.json
grep -A2 '"commons"' /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-core-api/composer.json
```

- [ ] **PF-4: Confirm deep-link URL shapes.** Visit the GTM Monitor module and a QA Datalayer run-detail page in the running app. Note the URL patterns. These will be hard-coded into the `action_url` of each notification.

Drafts:
- GTM Monitor: `/projects/{projectId}/gtm-monitor?version={publishedVersionId}`
- QA Audit:    `/projects/{projectId}/qa-datalayer/jobs/{qaJobId}`

Confirm and update Phase C tasks.

---

# Phase A: Shared models in `commons` + migrations

## Task A1: Add `Notification` model to `commons`

**Files:**
- Create: `commons/src/Models/Notification.php`
- Test: `commons/tests/Unit/Models/NotificationTest.php` (create if `tests/Unit/Models/` does not exist)

- [ ] **Step 1: Write the failing test**

```php
<?php
// commons/tests/Unit/Models/NotificationTest.php

namespace TagInsight\Commons\Tests\Unit\Models;

use TagInsight\Commons\Models\Notification;
use PHPUnit\Framework\TestCase;

class NotificationTest extends TestCase
{
    public function test_notification_has_expected_fillable_fields(): void
    {
        $expected = [
            'id',
            'user_id',
            'client_id',
            'project_id',
            'type',
            'title',
            'body',
            'payload',
            'action_url',
            'read_at',
            'archived_at',
        ];

        $notification = new Notification();
        $this->assertSame($expected, $notification->getFillable());
    }

    public function test_payload_is_cast_to_array(): void
    {
        $notification = new Notification();
        $this->assertSame('array', $notification->getCasts()['payload'] ?? null);
    }

    public function test_read_at_and_archived_at_are_cast_to_datetime(): void
    {
        $notification = new Notification();
        $casts = $notification->getCasts();
        $this->assertSame('datetime', $casts['read_at'] ?? null);
        $this->assertSame('datetime', $casts['archived_at'] ?? null);
    }
}
```

Replace the `TagInsight\Commons` namespace prefix with whatever the existing models use (check `commons/src/Models/User.php` first). Use the same prefix everywhere in this plan.

- [ ] **Step 2: Run test to verify it fails**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/commons
vendor/bin/phpunit tests/Unit/Models/NotificationTest.php
```

Expected: FAIL, "Class Notification does not exist".

- [ ] **Step 3: Implement the model**

```php
<?php
// commons/src/Models/Notification.php

namespace TagInsight\Commons\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class Notification extends Model
{
    use HasUuids;

    protected $table = 'notifications';

    public $incrementing = false;
    protected $keyType = 'string';

    protected $fillable = [
        'id',
        'user_id',
        'client_id',
        'project_id',
        'type',
        'title',
        'body',
        'payload',
        'action_url',
        'read_at',
        'archived_at',
    ];

    protected $casts = [
        'payload' => 'array',
        'read_at' => 'datetime',
        'archived_at' => 'datetime',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function client(): BelongsTo
    {
        return $this->belongsTo(Client::class);
    }

    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

```bash
vendor/bin/phpunit tests/Unit/Models/NotificationTest.php
```

Expected: PASS, 3 tests.

- [ ] **Step 5: Commit**

```bash
git add src/Models/Notification.php tests/Unit/Models/NotificationTest.php
git commit -m "feat(notifications): add Notification model in commons"
```

---

## Task A2: Add `NotificationPreference` model

**Files:**
- Create: `commons/src/Models/NotificationPreference.php`
- Test: `commons/tests/Unit/Models/NotificationPreferenceTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// commons/tests/Unit/Models/NotificationPreferenceTest.php

namespace TagInsight\Commons\Tests\Unit\Models;

use TagInsight\Commons\Models\NotificationPreference;
use PHPUnit\Framework\TestCase;

class NotificationPreferenceTest extends TestCase
{
    public function test_has_expected_fillable_fields(): void
    {
        $pref = new NotificationPreference();
        $this->assertSame(
            ['id', 'user_id', 'type', 'project_id', 'enabled'],
            $pref->getFillable()
        );
    }

    public function test_enabled_is_cast_to_boolean(): void
    {
        $pref = new NotificationPreference();
        $this->assertSame('boolean', $pref->getCasts()['enabled'] ?? null);
    }

    public function test_enabled_defaults_to_true(): void
    {
        $pref = new NotificationPreference();
        $this->assertTrue($pref->enabled ?? true);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
vendor/bin/phpunit tests/Unit/Models/NotificationPreferenceTest.php
```

Expected: FAIL, "Class NotificationPreference does not exist".

- [ ] **Step 3: Implement the model**

```php
<?php
// commons/src/Models/NotificationPreference.php

namespace TagInsight\Commons\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class NotificationPreference extends Model
{
    use HasUuids;

    protected $table = 'notification_preferences';

    public $incrementing = false;
    protected $keyType = 'string';

    protected $fillable = [
        'id',
        'user_id',
        'type',
        'project_id',
        'enabled',
    ];

    protected $casts = [
        'enabled' => 'boolean',
    ];

    protected $attributes = [
        'enabled' => true,
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }

    public function project(): BelongsTo
    {
        return $this->belongsTo(Project::class);
    }
}
```

- [ ] **Step 4: Run test to verify it passes**

```bash
vendor/bin/phpunit tests/Unit/Models/NotificationPreferenceTest.php
```

Expected: PASS, 3 tests.

- [ ] **Step 5: Commit**

```bash
git add src/Models/NotificationPreference.php tests/Unit/Models/NotificationPreferenceTest.php
git commit -m "feat(notifications): add NotificationPreference model in commons"
```

---

## Task A3: Add `notifications`/`notificationPreferences` relations on `User`

**Files:**
- Modify: `commons/src/Models/User.php`
- Test: `commons/tests/Unit/Models/UserNotificationRelationsTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// commons/tests/Unit/Models/UserNotificationRelationsTest.php

namespace TagInsight\Commons\Tests\Unit\Models;

use TagInsight\Commons\Models\User;
use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\NotificationPreference;
use PHPUnit\Framework\TestCase;

class UserNotificationRelationsTest extends TestCase
{
    public function test_user_has_notifications_relation(): void
    {
        $user = new User();
        $relation = $user->notifications();
        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $relation);
        $this->assertSame(Notification::class, get_class($relation->getRelated()));
    }

    public function test_user_has_notification_preferences_relation(): void
    {
        $user = new User();
        $relation = $user->notificationPreferences();
        $this->assertInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class, $relation);
        $this->assertSame(NotificationPreference::class, get_class($relation->getRelated()));
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
vendor/bin/phpunit tests/Unit/Models/UserNotificationRelationsTest.php
```

Expected: FAIL, "Call to undefined method notifications()".

- [ ] **Step 3: Add the relations to `User`**

In `commons/src/Models/User.php`, add (placing the methods alongside the existing `clients()` relation):

```php
use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\NotificationPreference;
use Illuminate\Database\Eloquent\Relations\HasMany;

public function notifications(): HasMany
{
    return $this->hasMany(Notification::class);
}

public function notificationPreferences(): HasMany
{
    return $this->hasMany(NotificationPreference::class);
}
```

- [ ] **Step 4: Run test to verify it passes**

```bash
vendor/bin/phpunit tests/Unit/Models/UserNotificationRelationsTest.php
```

Expected: PASS, 2 tests.

- [ ] **Step 5: Commit**

```bash
git add src/Models/User.php tests/Unit/Models/UserNotificationRelationsTest.php
git commit -m "feat(notifications): add notifications + preferences relations on User"
```

---

## Task A4: Add shared TypeScript types

**Files:**
- Create: `commons/src/types/notification.ts`
- Modify: `commons/src/index.ts` (or main barrel file, find via `cat commons/package.json | grep main`)

- [ ] **Step 1: Implement the type module**

```ts
// commons/src/types/notification.ts

export type NotificationType =
  | 'gtm.new_version'
  | 'qa.audit_finished'
  | 'qa.audit_failed';

export interface Notification {
  id: string;
  user_id: string;
  client_id: string;
  project_id: string | null;
  type: NotificationType;
  title: string;
  body: string;
  payload: Record<string, unknown>;
  action_url: string | null;
  read_at: string | null;
  archived_at: string | null;
  created_at: string;
  updated_at: string;
}

export interface NotificationPreferenceOverride {
  project_id: string;
  project_name: string;
  enabled: boolean;
}

export interface NotificationPreference {
  type: NotificationType;
  enabled: boolean;
  overrides: NotificationPreferenceOverride[];
}

export interface UnreadCountResponse {
  count: number;
  by_client: Record<string, number>;
}

export interface PaginatedNotifications {
  data: Notification[];
  next_cursor: string | null;
}
```

- [ ] **Step 2: Re-export from barrel file**

Append to `commons/src/index.ts` (or whichever file is the package main):

```ts
export * from './types/notification';
```

- [ ] **Step 3: Build the package**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/commons
yarn build
```

Expected: build succeeds, no TypeScript errors.

- [ ] **Step 4: Commit**

```bash
git add src/types/notification.ts src/index.ts
git commit -m "feat(notifications): add shared TS notification types"
```

---

## Task A5: Tag and release new `commons` version

- [ ] **Step 1: Bump version**

Edit `commons/composer.json` and `commons/package.json`: bump `version` field (e.g., `1.2.0` → `1.3.0`). Match whichever versioning scheme the repo uses.

- [ ] **Step 2: Commit + tag**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/commons
git add composer.json package.json
git commit -m "chore(commons): bump version for notifications models"
git tag v1.3.0  # use actual new version
git push origin feature/notifications-phase1
git push origin v1.3.0
```

- [ ] **Step 3: Open PR**

Open PR `commons` repo to `dev`, reviewers @nyfen + @joel. Block Phase B/C until merged + tag pushed.

---

# Phase B: Core API (REST endpoints, queue job, retention command)

> **All paths below are inside `saas-core-api/`.**

## Task B1: Migration for `notifications` table

**Files:**
- Create: `database/migrations/YYYY_MM_DD_HHMMSS_create_notifications_table.php` (use `php artisan make:migration create_notifications_table` to generate the actual filename)

- [ ] **Step 1: Generate migration**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-core-api
docker compose exec saas-core-api php artisan make:migration create_notifications_table
```

- [ ] **Step 2: Implement the migration**

Replace the generated file's `up` / `down`:

```php
public function up(): void
{
    Schema::create('notifications', function (Blueprint $table) {
        $table->uuid('id')->primary();
        $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
        $table->foreignUuid('client_id')->constrained('clients')->cascadeOnDelete();
        $table->foreignUuid('project_id')->nullable()->constrained('projects')->nullOnDelete();
        $table->string('type', 64);
        $table->string('title', 255);
        $table->text('body');
        $table->json('payload')->nullable();
        $table->string('action_url', 1024)->nullable();
        $table->timestamp('read_at')->nullable();
        $table->timestamp('archived_at')->nullable();
        $table->timestamps();

        $table->index(['user_id', 'archived_at', 'read_at', 'created_at'], 'notif_inbox_idx');
        $table->index(['archived_at', 'created_at'], 'notif_sweep_idx');
        $table->index(['user_id', 'type', 'created_at'], 'notif_type_idx');
    });
}

public function down(): void
{
    Schema::dropIfExists('notifications');
}
```

- [ ] **Step 3: Run the migration**

```bash
docker compose exec saas-core-api php artisan migrate
```

Expected: "Migrated: ... create_notifications_table".

- [ ] **Step 4: Verify schema**

```bash
docker compose exec saas-core-api php artisan tinker --execute="dd(Schema::getColumnListing('notifications'));"
```

Expected: array containing all the columns from the migration.

- [ ] **Step 5: Commit**

```bash
git add database/migrations/*_create_notifications_table.php
git commit -m "feat(notifications): add notifications table migration"
```

---

## Task B2: Migration for `notification_preferences` table

**Files:**
- Create: `database/migrations/YYYY_MM_DD_HHMMSS_create_notification_preferences_table.php`

- [ ] **Step 1: Generate migration**

```bash
docker compose exec saas-core-api php artisan make:migration create_notification_preferences_table
```

- [ ] **Step 2: Implement the migration**

```php
public function up(): void
{
    Schema::create('notification_preferences', function (Blueprint $table) {
        $table->uuid('id')->primary();
        $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
        $table->string('type', 64);
        $table->foreignUuid('project_id')->nullable()->constrained('projects')->nullOnDelete();
        $table->boolean('enabled')->default(true);
        $table->timestamps();

        $table->index(['user_id', 'type'], 'notifprefs_lookup_idx');
    });

    // MySQL treats NULL as distinct in unique indexes, so add a generated column
    // and place the unique index on it. This blocks duplicate (user_id, type, NULL) rows.
    DB::statement("
        ALTER TABLE notification_preferences
        ADD COLUMN project_id_key CHAR(36) AS (IFNULL(project_id, '00000000-0000-0000-0000-000000000000')) STORED,
        ADD UNIQUE INDEX notifprefs_unique (user_id, type, project_id_key)
    ");
}

public function down(): void
{
    Schema::dropIfExists('notification_preferences');
}
```

Add `use Illuminate\Support\Facades\DB;` at the top.

- [ ] **Step 3: Run migration**

```bash
docker compose exec saas-core-api php artisan migrate
```

Expected: "Migrated: ... create_notification_preferences_table".

- [ ] **Step 4: Verify uniqueness works**

```bash
docker compose exec saas-core-api php artisan tinker
```

Inside tinker:

```php
$u = \App\Models\User::first();
\TagInsight\Commons\Models\NotificationPreference::create(['user_id' => $u->id, 'type' => 'gtm.new_version', 'project_id' => null, 'enabled' => false]);
try {
    \TagInsight\Commons\Models\NotificationPreference::create(['user_id' => $u->id, 'type' => 'gtm.new_version', 'project_id' => null, 'enabled' => true]);
    echo "FAIL: duplicate inserted\n";
} catch (\Exception $e) {
    echo "PASS: duplicate blocked\n";
}
\TagInsight\Commons\Models\NotificationPreference::where(['user_id' => $u->id, 'type' => 'gtm.new_version'])->delete();
exit;
```

Expected: "PASS: duplicate blocked".

- [ ] **Step 5: Commit**

```bash
git add database/migrations/*_create_notification_preferences_table.php
git commit -m "feat(notifications): add notification_preferences table migration"
```

---

## Task B3: `NotificationPolicy`

**Files:**
- Create: `app/Policies/NotificationPolicy.php`
- Modify: `app/Providers/AuthServiceProvider.php`
- Test: `tests/Unit/Policies/NotificationPolicyTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Unit/Policies/NotificationPolicyTest.php

namespace Tests\Unit\Policies;

use App\Policies\NotificationPolicy;
use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\User;
use Tests\TestCase;

class NotificationPolicyTest extends TestCase
{
    public function test_user_can_view_own_notification(): void
    {
        $user = User::factory()->create();
        $notification = Notification::factory()->create(['user_id' => $user->id]);

        $policy = new NotificationPolicy();
        $this->assertTrue($policy->view($user, $notification));
    }

    public function test_user_cannot_view_other_users_notification(): void
    {
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $notification = Notification::factory()->create(['user_id' => $alice->id]);

        $policy = new NotificationPolicy();
        $this->assertFalse($policy->view($bob, $notification));
    }
}
```

If `Notification::factory()` does not exist, create it in `database/factories/NotificationFactory.php`:

```php
<?php
namespace Database\Factories;

use TagInsight\Commons\Models\Notification;
use Illuminate\Database\Eloquent\Factories\Factory;

class NotificationFactory extends Factory
{
    protected $model = Notification::class;

    public function definition(): array
    {
        return [
            'user_id' => \TagInsight\Commons\Models\User::factory(),
            'client_id' => \TagInsight\Commons\Models\Client::factory(),
            'project_id' => null,
            'type' => 'gtm.new_version',
            'title' => $this->faker->sentence(4),
            'body' => $this->faker->sentence(8),
            'payload' => [],
            'action_url' => null,
        ];
    }
}
```

Add the `HasFactory` trait + `newFactory()` method on the `Notification` model (in `commons`), or use Laravel's auto-resolution by namespace.

- [ ] **Step 2: Run test to verify it fails**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationPolicyTest
```

Expected: FAIL, "Class NotificationPolicy does not exist".

- [ ] **Step 3: Implement the policy**

```php
<?php
// app/Policies/NotificationPolicy.php

namespace App\Policies;

use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\User;

class NotificationPolicy
{
    public function view(User $user, Notification $notification): bool
    {
        return $notification->user_id === $user->id;
    }

    public function update(User $user, Notification $notification): bool
    {
        return $notification->user_id === $user->id;
    }

    public function archive(User $user, Notification $notification): bool
    {
        return $notification->user_id === $user->id;
    }
}
```

- [ ] **Step 4: Register the policy**

In `app/Providers/AuthServiceProvider.php`, add to `$policies`:

```php
protected $policies = [
    // ... existing ...
    \TagInsight\Commons\Models\Notification::class => \App\Policies\NotificationPolicy::class,
];
```

- [ ] **Step 5: Run test to verify it passes**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationPolicyTest
```

Expected: PASS, 2 tests.

- [ ] **Step 6: Commit**

```bash
git add app/Policies/NotificationPolicy.php app/Providers/AuthServiceProvider.php database/factories/NotificationFactory.php tests/Unit/Policies/NotificationPolicyTest.php
git commit -m "feat(notifications): add NotificationPolicy + factory"
```

---

## Task B4: `SendNotificationJob` (recipient resolution + bulk insert)

**Files:**
- Create: `app/Jobs/SendNotificationJob.php`
- Test: `tests/Feature/Jobs/SendNotificationJobTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Feature/Jobs/SendNotificationJobTest.php

namespace Tests\Feature\Jobs;

use App\Jobs\SendNotificationJob;
use TagInsight\Commons\Models\Client;
use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\NotificationPreference;
use TagInsight\Commons\Models\Project;
use TagInsight\Commons\Models\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class SendNotificationJobTest extends TestCase
{
    use RefreshDatabase;

    public function test_creates_notification_for_every_user_in_client(): void
    {
        $client = Client::factory()->create();
        $project = Project::factory()->create(['client_id' => $client->id]);
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $alice->clients()->attach($client->id);
        $bob->clients()->attach($client->id);

        (new SendNotificationJob(
            type: 'gtm.new_version',
            clientId: $client->id,
            projectId: $project->id,
            title: 'New version',
            body: 'Container X published version 42',
            payload: ['published_version_id' => 'abc'],
            actionUrl: '/projects/'.$project->id.'/gtm-monitor?version=abc',
        ))->handle();

        $this->assertSame(2, Notification::count());
        $this->assertTrue(Notification::where('user_id', $alice->id)->exists());
        $this->assertTrue(Notification::where('user_id', $bob->id)->exists());
    }

    public function test_skips_users_who_muted_the_type_globally(): void
    {
        $client = Client::factory()->create();
        $project = Project::factory()->create(['client_id' => $client->id]);
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $alice->clients()->attach($client->id);
        $bob->clients()->attach($client->id);

        NotificationPreference::create([
            'user_id' => $bob->id,
            'type' => 'gtm.new_version',
            'project_id' => null,
            'enabled' => false,
        ]);

        (new SendNotificationJob(
            type: 'gtm.new_version',
            clientId: $client->id,
            projectId: $project->id,
            title: 't',
            body: 'b',
            payload: [],
            actionUrl: null,
        ))->handle();

        $this->assertSame(1, Notification::count());
        $this->assertTrue(Notification::where('user_id', $alice->id)->exists());
        $this->assertFalse(Notification::where('user_id', $bob->id)->exists());
    }

    public function test_skips_users_who_muted_the_type_for_this_project(): void
    {
        $client = Client::factory()->create();
        $project = Project::factory()->create(['client_id' => $client->id]);
        $alice = User::factory()->create();
        $alice->clients()->attach($client->id);

        NotificationPreference::create([
            'user_id' => $alice->id,
            'type' => 'gtm.new_version',
            'project_id' => $project->id,
            'enabled' => false,
        ]);

        (new SendNotificationJob(
            type: 'gtm.new_version',
            clientId: $client->id,
            projectId: $project->id,
            title: 't',
            body: 'b',
            payload: [],
            actionUrl: null,
        ))->handle();

        $this->assertSame(0, Notification::count());
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
docker compose exec saas-core-api php artisan test --filter=SendNotificationJobTest
```

Expected: FAIL, "Class SendNotificationJob does not exist".

- [ ] **Step 3: Implement the job**

```php
<?php
// app/Jobs/SendNotificationJob.php

namespace App\Jobs;

use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;

class SendNotificationJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        public string $type,
        public string $clientId,
        public ?string $projectId,
        public string $title,
        public string $body,
        public array $payload,
        public ?string $actionUrl,
    ) {}

    public function handle(): void
    {
        $start = microtime(true);

        $recipients = User::whereHas('clients', fn ($q) => $q->where('clients.id', $this->clientId))
            ->whereDoesntHave('notificationPreferences', function ($q) {
                $q->where('type', $this->type)
                  ->where('enabled', false)
                  ->where(function ($q2) {
                      $q2->whereNull('project_id');
                      if ($this->projectId !== null) {
                          $q2->orWhere('project_id', $this->projectId);
                      }
                  });
            })
            ->select('id')
            ->get();

        if ($recipients->isEmpty()) {
            Log::info('notifications.skipped_no_recipients', [
                'type' => $this->type,
                'client_id' => $this->clientId,
                'project_id' => $this->projectId,
            ]);
            return;
        }

        $now = now();
        $rows = $recipients->map(fn ($user) => [
            'id' => (string) Str::uuid(),
            'user_id' => $user->id,
            'client_id' => $this->clientId,
            'project_id' => $this->projectId,
            'type' => $this->type,
            'title' => $this->title,
            'body' => $this->body,
            'payload' => json_encode($this->payload),
            'action_url' => $this->actionUrl,
            'read_at' => null,
            'archived_at' => null,
            'created_at' => $now,
            'updated_at' => $now,
        ])->all();

        Notification::insert($rows);

        // TODO Phase 2 (Reverb): broadcast(new NotificationCreated($row)) per row

        Log::info('notifications.sent', [
            'type' => $this->type,
            'recipient_count' => count($rows),
            'duration_ms' => round((microtime(true) - $start) * 1000),
        ]);
    }
}
```

- [ ] **Step 4: Run tests to verify they pass**

```bash
docker compose exec saas-core-api php artisan test --filter=SendNotificationJobTest
```

Expected: PASS, 3 tests.

- [ ] **Step 5: Commit**

```bash
git add app/Jobs/SendNotificationJob.php tests/Feature/Jobs/SendNotificationJobTest.php
git commit -m "feat(notifications): add SendNotificationJob with mute-aware recipient resolution"
```

---

## Task B5: `NotificationController` skeleton + index endpoint

**Files:**
- Create: `app/Http/Controllers/Api/NotificationController.php`
- Create: `app/Http/Resources/NotificationResource.php`
- Modify: `routes/api.php`
- Test: `tests/Feature/Http/NotificationIndexTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Feature/Http/NotificationIndexTest.php

namespace Tests\Feature\Http;

use TagInsight\Commons\Models\Client;
use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class NotificationIndexTest extends TestCase
{
    use RefreshDatabase;

    public function test_returns_only_my_active_inbox_notifications(): void
    {
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $client = Client::factory()->create();
        $alice->clients()->attach($client->id);

        $mine = Notification::factory()->create(['user_id' => $alice->id, 'client_id' => $client->id]);
        $someoneElses = Notification::factory()->create(['user_id' => $bob->id, 'client_id' => $client->id]);
        $archived = Notification::factory()->create([
            'user_id' => $alice->id,
            'client_id' => $client->id,
            'archived_at' => now(),
        ]);

        $resp = $this->actingAs($alice)
            ->getJson("/api/notifications?client_id={$client->id}");

        $resp->assertOk();
        $ids = collect($resp->json('data'))->pluck('id')->all();
        $this->assertContains($mine->id, $ids);
        $this->assertNotContains($someoneElses->id, $ids);
        $this->assertNotContains($archived->id, $ids);
    }

    public function test_archived_param_returns_archived(): void
    {
        $alice = User::factory()->create();
        $client = Client::factory()->create();
        $alice->clients()->attach($client->id);

        Notification::factory()->create(['user_id' => $alice->id, 'client_id' => $client->id]);
        $archived = Notification::factory()->create([
            'user_id' => $alice->id,
            'client_id' => $client->id,
            'archived_at' => now(),
        ]);

        $resp = $this->actingAs($alice)
            ->getJson("/api/notifications?client_id={$client->id}&archived=1");

        $ids = collect($resp->json('data'))->pluck('id')->all();
        $this->assertSame([$archived->id], $ids);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationIndexTest
```

Expected: FAIL, route or class not found.

- [ ] **Step 3: Implement the resource**

```php
<?php
// app/Http/Resources/NotificationResource.php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class NotificationResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id' => $this->id,
            'user_id' => $this->user_id,
            'client_id' => $this->client_id,
            'project_id' => $this->project_id,
            'type' => $this->type,
            'title' => $this->title,
            'body' => $this->body,
            'payload' => $this->payload,
            'action_url' => $this->action_url,
            'read_at' => $this->read_at?->toIso8601String(),
            'archived_at' => $this->archived_at?->toIso8601String(),
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),
        ];
    }
}
```

- [ ] **Step 4: Implement the controller index method**

```php
<?php
// app/Http/Controllers/Api/NotificationController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\NotificationResource;
use TagInsight\Commons\Models\Notification;
use Illuminate\Http\Request;

class NotificationController extends Controller
{
    public function index(Request $request)
    {
        $request->validate([
            'client_id' => 'required|string',
            'archived' => 'sometimes|boolean',
            'read' => 'sometimes|in:true,false',
            'type' => 'sometimes|array',
            'type.*' => 'string|in:gtm.new_version,qa.audit_finished,qa.audit_failed',
            'project_id' => 'sometimes|array',
            'project_id.*' => 'string',
            'per_page' => 'sometimes|integer|min:1|max:100',
        ]);

        $user = $request->user();
        $query = Notification::query()->where('user_id', $user->id);

        if ($request->input('client_id') === 'all') {
            $clientIds = $user->clients()->pluck('clients.id');
            $query->whereIn('client_id', $clientIds);
        } else {
            $query->where('client_id', $request->input('client_id'));
        }

        if ($request->boolean('archived')) {
            $query->whereNotNull('archived_at');
        } else {
            $query->whereNull('archived_at');
        }

        if ($request->has('read')) {
            $read = $request->input('read') === 'true';
            $read
                ? $query->whereNotNull('read_at')
                : $query->whereNull('read_at');
        }

        if ($request->has('type')) {
            $query->whereIn('type', $request->input('type'));
        }

        if ($request->has('project_id')) {
            $query->whereIn('project_id', $request->input('project_id'));
        }

        $perPage = (int) $request->input('per_page', 20);

        $page = $query->orderByDesc('created_at')->cursorPaginate($perPage);

        return NotificationResource::collection($page)->additional([
            'next_cursor' => $page->nextCursor()?->encode(),
        ]);
    }
}
```

- [ ] **Step 5: Register route**

In `routes/api.php`, inside the authenticated group (find existing `Route::middleware('auth:sanctum')->group(...)`):

```php
use App\Http\Controllers\Api\NotificationController;

Route::prefix('notifications')->group(function () {
    Route::get('/', [NotificationController::class, 'index']);
});
```

- [ ] **Step 6: Run test to verify it passes**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationIndexTest
```

Expected: PASS, 2 tests.

- [ ] **Step 7: Commit**

```bash
git add app/Http/Controllers/Api/NotificationController.php app/Http/Resources/NotificationResource.php routes/api.php tests/Feature/Http/NotificationIndexTest.php
git commit -m "feat(notifications): GET /api/notifications inbox endpoint"
```

---

## Task B6: `unread-count` endpoint

**Files:**
- Modify: `app/Http/Controllers/Api/NotificationController.php`
- Modify: `routes/api.php`
- Test: `tests/Feature/Http/NotificationUnreadCountTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Feature/Http/NotificationUnreadCountTest.php

namespace Tests\Feature\Http;

use TagInsight\Commons\Models\Client;
use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class NotificationUnreadCountTest extends TestCase
{
    use RefreshDatabase;

    public function test_returns_unread_count_with_per_client_breakdown(): void
    {
        $alice = User::factory()->create();
        $c1 = Client::factory()->create();
        $c2 = Client::factory()->create();
        $alice->clients()->attach([$c1->id, $c2->id]);

        Notification::factory()->count(3)->create(['user_id' => $alice->id, 'client_id' => $c1->id]);
        Notification::factory()->count(2)->create(['user_id' => $alice->id, 'client_id' => $c2->id]);
        Notification::factory()->create(['user_id' => $alice->id, 'client_id' => $c1->id, 'read_at' => now()]);
        Notification::factory()->create(['user_id' => $alice->id, 'client_id' => $c1->id, 'archived_at' => now()]);

        $resp = $this->actingAs($alice)->getJson('/api/notifications/unread-count');

        $resp->assertOk()
            ->assertJson([
                'count' => 5,
                'by_client' => [
                    $c1->id => 3,
                    $c2->id => 2,
                ],
            ]);
    }
}
```

- [ ] **Step 2: Run test to verify it fails**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationUnreadCountTest
```

Expected: FAIL, 404.

- [ ] **Step 3: Implement the method**

Add to `NotificationController`:

```php
use Illuminate\Support\Facades\DB;

public function unreadCount(Request $request)
{
    $user = $request->user();
    $clientIds = $user->clients()->pluck('clients.id');

    $rows = Notification::query()
        ->select('client_id', DB::raw('COUNT(*) as cnt'))
        ->where('user_id', $user->id)
        ->whereIn('client_id', $clientIds)
        ->whereNull('read_at')
        ->whereNull('archived_at')
        ->groupBy('client_id')
        ->get();

    $byClient = $rows->pluck('cnt', 'client_id')->map(fn ($n) => (int) $n)->all();
    $total = array_sum($byClient);

    return response()->json([
        'count' => $total,
        'by_client' => $byClient,
    ]);
}
```

- [ ] **Step 4: Register the route**

In `routes/api.php`, inside the `notifications` group:

```php
Route::get('/unread-count', [NotificationController::class, 'unreadCount']);
```

> Place this BEFORE any wildcard routes (`{id}`) to avoid being shadowed.

- [ ] **Step 5: Run test to verify it passes**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationUnreadCountTest
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add app/Http/Controllers/Api/NotificationController.php routes/api.php tests/Feature/Http/NotificationUnreadCountTest.php
git commit -m "feat(notifications): GET /api/notifications/unread-count"
```

---

## Task B7: Mark-read, mark-unread, mark-all-read endpoints

**Files:**
- Modify: `app/Http/Controllers/Api/NotificationController.php`
- Modify: `routes/api.php`
- Test: `tests/Feature/Http/NotificationReadTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Feature/Http/NotificationReadTest.php

namespace Tests\Feature\Http;

use TagInsight\Commons\Models\Client;
use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class NotificationReadTest extends TestCase
{
    use RefreshDatabase;

    public function test_can_mark_own_notification_read(): void
    {
        $u = User::factory()->create();
        $n = Notification::factory()->create(['user_id' => $u->id]);

        $this->actingAs($u)->postJson("/api/notifications/{$n->id}/read")->assertNoContent();

        $this->assertNotNull($n->fresh()->read_at);
    }

    public function test_cannot_mark_someone_elses_notification_read(): void
    {
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $n = Notification::factory()->create(['user_id' => $alice->id]);

        $this->actingAs($bob)->postJson("/api/notifications/{$n->id}/read")->assertForbidden();
    }

    public function test_mark_all_read_respects_client_filter(): void
    {
        $u = User::factory()->create();
        $c1 = Client::factory()->create();
        $c2 = Client::factory()->create();
        $u->clients()->attach([$c1->id, $c2->id]);

        $n1 = Notification::factory()->create(['user_id' => $u->id, 'client_id' => $c1->id]);
        $n2 = Notification::factory()->create(['user_id' => $u->id, 'client_id' => $c2->id]);

        $this->actingAs($u)
            ->postJson('/api/notifications/mark-all-read', ['client_id' => $c1->id])
            ->assertNoContent();

        $this->assertNotNull($n1->fresh()->read_at);
        $this->assertNull($n2->fresh()->read_at);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationReadTest
```

Expected: FAIL.

- [ ] **Step 3: Implement the controller methods**

Add to `NotificationController`:

```php
public function markRead(Request $request, string $id)
{
    $notification = Notification::findOrFail($id);
    $this->authorize('update', $notification);

    if ($notification->read_at === null) {
        $notification->update(['read_at' => now()]);
    }

    return response()->noContent();
}

public function markUnread(Request $request, string $id)
{
    $notification = Notification::findOrFail($id);
    $this->authorize('update', $notification);

    if ($notification->read_at !== null) {
        $notification->update(['read_at' => null]);
    }

    return response()->noContent();
}

public function markAllRead(Request $request)
{
    $request->validate([
        'client_id' => 'sometimes|string',
        'type' => 'sometimes|array',
        'type.*' => 'string',
    ]);

    $user = $request->user();
    $q = Notification::query()->where('user_id', $user->id)->whereNull('read_at')->whereNull('archived_at');

    if ($request->has('client_id') && $request->input('client_id') !== 'all') {
        $q->where('client_id', $request->input('client_id'));
    }
    if ($request->has('type')) {
        $q->whereIn('type', $request->input('type'));
    }

    $q->update(['read_at' => now()]);

    return response()->noContent();
}
```

Make sure the controller `use AuthorizesRequests;` trait is present (`use Illuminate\Foundation\Auth\Access\AuthorizesRequests;` and `use AuthorizesRequests;` in the class body). Laravel's base `Controller` usually includes it; verify.

- [ ] **Step 4: Register routes**

```php
Route::post('/{id}/read', [NotificationController::class, 'markRead']);
Route::post('/{id}/unread', [NotificationController::class, 'markUnread']);
Route::post('/mark-all-read', [NotificationController::class, 'markAllRead']);
```

> `mark-all-read` must be registered BEFORE `{id}/read` or it will be matched as id=`mark-all-read`. Actually, since the method is `POST /mark-all-read` and `POST /{id}/read` is a different path shape, Laravel's router will resolve them correctly. But put the static one first for clarity.

- [ ] **Step 5: Run tests to verify they pass**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationReadTest
```

Expected: PASS, 3 tests.

- [ ] **Step 6: Commit**

```bash
git add app/Http/Controllers/Api/NotificationController.php routes/api.php tests/Feature/Http/NotificationReadTest.php
git commit -m "feat(notifications): mark-read, mark-unread, mark-all-read endpoints"
```

---

## Task B8: Archive endpoints (single + bulk)

**Files:**
- Modify: `app/Http/Controllers/Api/NotificationController.php`
- Modify: `routes/api.php`
- Test: `tests/Feature/Http/NotificationArchiveTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Feature/Http/NotificationArchiveTest.php

namespace Tests\Feature\Http;

use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class NotificationArchiveTest extends TestCase
{
    use RefreshDatabase;

    public function test_archive_sets_archived_at(): void
    {
        $u = User::factory()->create();
        $n = Notification::factory()->create(['user_id' => $u->id]);

        $this->actingAs($u)->postJson("/api/notifications/{$n->id}/archive")->assertNoContent();

        $this->assertNotNull($n->fresh()->archived_at);
    }

    public function test_cannot_archive_someone_elses(): void
    {
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $n = Notification::factory()->create(['user_id' => $alice->id]);

        $this->actingAs($bob)->postJson("/api/notifications/{$n->id}/archive")->assertForbidden();
    }

    public function test_bulk_archive_only_archives_own(): void
    {
        $alice = User::factory()->create();
        $bob = User::factory()->create();
        $n1 = Notification::factory()->create(['user_id' => $alice->id]);
        $n2 = Notification::factory()->create(['user_id' => $alice->id]);
        $n3 = Notification::factory()->create(['user_id' => $bob->id]);

        $this->actingAs($alice)
            ->postJson('/api/notifications/bulk-archive', ['ids' => [$n1->id, $n2->id, $n3->id]])
            ->assertNoContent();

        $this->assertNotNull($n1->fresh()->archived_at);
        $this->assertNotNull($n2->fresh()->archived_at);
        $this->assertNull($n3->fresh()->archived_at);
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationArchiveTest
```

Expected: FAIL.

- [ ] **Step 3: Implement the methods**

```php
public function archive(Request $request, string $id)
{
    $notification = Notification::findOrFail($id);
    $this->authorize('archive', $notification);

    if ($notification->archived_at === null) {
        $notification->update(['archived_at' => now()]);
    }

    return response()->noContent();
}

public function bulkArchive(Request $request)
{
    $request->validate([
        'ids' => 'required|array',
        'ids.*' => 'string',
    ]);

    Notification::query()
        ->whereIn('id', $request->input('ids'))
        ->where('user_id', $request->user()->id)
        ->whereNull('archived_at')
        ->update(['archived_at' => now()]);

    return response()->noContent();
}
```

- [ ] **Step 4: Register routes**

```php
Route::post('/bulk-archive', [NotificationController::class, 'bulkArchive']);
Route::post('/{id}/archive', [NotificationController::class, 'archive']);
```

> Register `bulk-archive` BEFORE `{id}/archive` because Laravel matches in order.

- [ ] **Step 5: Run tests to verify they pass**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationArchiveTest
```

Expected: PASS, 3 tests.

- [ ] **Step 6: Commit**

```bash
git add app/Http/Controllers/Api/NotificationController.php routes/api.php tests/Feature/Http/NotificationArchiveTest.php
git commit -m "feat(notifications): archive + bulk-archive endpoints"
```

---

## Task B9: Preferences endpoints

**Files:**
- Create: `app/Http/Controllers/Api/NotificationPreferenceController.php`
- Create: `app/Http/Resources/NotificationPreferenceResource.php`
- Modify: `routes/api.php`
- Test: `tests/Feature/Http/NotificationPreferencesTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Feature/Http/NotificationPreferencesTest.php

namespace Tests\Feature\Http;

use TagInsight\Commons\Models\Client;
use TagInsight\Commons\Models\NotificationPreference;
use TagInsight\Commons\Models\Project;
use TagInsight\Commons\Models\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class NotificationPreferencesTest extends TestCase
{
    use RefreshDatabase;

    public function test_index_returns_all_three_types_with_overrides(): void
    {
        $u = User::factory()->create();
        $c = Client::factory()->create();
        $p = Project::factory()->create(['client_id' => $c->id]);
        $u->clients()->attach($c->id);

        NotificationPreference::create(['user_id' => $u->id, 'type' => 'gtm.new_version', 'project_id' => null, 'enabled' => false]);
        NotificationPreference::create(['user_id' => $u->id, 'type' => 'qa.audit_finished', 'project_id' => $p->id, 'enabled' => false]);

        $resp = $this->actingAs($u)->getJson('/api/notification-preferences');

        $resp->assertOk();
        $prefs = collect($resp->json('preferences'));
        $this->assertCount(3, $prefs);

        $gtm = $prefs->firstWhere('type', 'gtm.new_version');
        $this->assertFalse($gtm['enabled']);
        $this->assertSame([], $gtm['overrides']);

        $qaFin = $prefs->firstWhere('type', 'qa.audit_finished');
        $this->assertTrue($qaFin['enabled']);
        $this->assertCount(1, $qaFin['overrides']);
        $this->assertFalse($qaFin['overrides'][0]['enabled']);
    }

    public function test_update_replaces_preferences_atomically(): void
    {
        $u = User::factory()->create();
        $c = Client::factory()->create();
        $p = Project::factory()->create(['client_id' => $c->id]);
        $u->clients()->attach($c->id);

        $payload = [
            'preferences' => [
                ['type' => 'gtm.new_version', 'enabled' => false, 'overrides' => []],
                ['type' => 'qa.audit_finished', 'enabled' => true, 'overrides' => [
                    ['project_id' => $p->id, 'enabled' => false],
                ]],
                ['type' => 'qa.audit_failed', 'enabled' => true, 'overrides' => []],
            ],
        ];

        $this->actingAs($u)->putJson('/api/notification-preferences', $payload)->assertNoContent();

        $this->assertSame(2, NotificationPreference::where('user_id', $u->id)->count());
        $this->assertTrue(NotificationPreference::where([
            'user_id' => $u->id, 'type' => 'gtm.new_version', 'project_id' => null, 'enabled' => false,
        ])->exists());
        $this->assertTrue(NotificationPreference::where([
            'user_id' => $u->id, 'type' => 'qa.audit_finished', 'project_id' => $p->id, 'enabled' => false,
        ])->exists());
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationPreferencesTest
```

Expected: FAIL.

- [ ] **Step 3: Implement the controller**

```php
<?php
// app/Http/Controllers/Api/NotificationPreferenceController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use TagInsight\Commons\Models\NotificationPreference;
use TagInsight\Commons\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class NotificationPreferenceController extends Controller
{
    private const TYPES = ['gtm.new_version', 'qa.audit_finished', 'qa.audit_failed'];

    public function index(Request $request)
    {
        $user = $request->user();
        $prefs = NotificationPreference::where('user_id', $user->id)->get();

        $projectNames = Project::query()
            ->whereIn('id', $prefs->pluck('project_id')->filter()->unique())
            ->pluck('name', 'id');

        $byType = $prefs->groupBy('type');

        $result = collect(self::TYPES)->map(function ($type) use ($byType, $projectNames) {
            $rows = $byType->get($type, collect());
            $global = $rows->firstWhere('project_id', null);
            $overrides = $rows->filter(fn ($r) => $r->project_id !== null)
                ->map(fn ($r) => [
                    'project_id' => $r->project_id,
                    'project_name' => $projectNames[$r->project_id] ?? '(deleted)',
                    'enabled' => $r->enabled,
                ])
                ->values()
                ->all();

            return [
                'type' => $type,
                'enabled' => $global ? $global->enabled : true,
                'overrides' => $overrides,
            ];
        });

        return response()->json(['preferences' => $result]);
    }

    public function update(Request $request)
    {
        $request->validate([
            'preferences' => 'required|array',
            'preferences.*.type' => 'required|string|in:'.implode(',', self::TYPES),
            'preferences.*.enabled' => 'required|boolean',
            'preferences.*.overrides' => 'sometimes|array',
            'preferences.*.overrides.*.project_id' => 'required|string',
            'preferences.*.overrides.*.enabled' => 'required|boolean',
        ]);

        $user = $request->user();

        DB::transaction(function () use ($request, $user) {
            NotificationPreference::where('user_id', $user->id)->delete();

            $rows = [];
            $now = now();
            foreach ($request->input('preferences') as $pref) {
                // Only persist a row when it differs from the default (enabled = true)
                if ($pref['enabled'] === false) {
                    $rows[] = [
                        'id' => (string) \Illuminate\Support\Str::uuid(),
                        'user_id' => $user->id,
                        'type' => $pref['type'],
                        'project_id' => null,
                        'enabled' => false,
                        'created_at' => $now,
                        'updated_at' => $now,
                    ];
                }
                foreach ($pref['overrides'] ?? [] as $override) {
                    if ($override['enabled'] === false) {
                        $rows[] = [
                            'id' => (string) \Illuminate\Support\Str::uuid(),
                            'user_id' => $user->id,
                            'type' => $pref['type'],
                            'project_id' => $override['project_id'],
                            'enabled' => false,
                            'created_at' => $now,
                            'updated_at' => $now,
                        ];
                    }
                }
            }

            if (!empty($rows)) {
                NotificationPreference::insert($rows);
            }
        });

        return response()->noContent();
    }
}
```

- [ ] **Step 4: Register routes**

```php
use App\Http\Controllers\Api\NotificationPreferenceController;

Route::get('/notification-preferences', [NotificationPreferenceController::class, 'index']);
Route::put('/notification-preferences', [NotificationPreferenceController::class, 'update']);
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationPreferencesTest
```

Expected: PASS, 2 tests.

- [ ] **Step 6: Commit**

```bash
git add app/Http/Controllers/Api/NotificationPreferenceController.php routes/api.php tests/Feature/Http/NotificationPreferencesTest.php
git commit -m "feat(notifications): notification-preferences endpoints"
```

---

## Task B10: Retention sweep command

**Files:**
- Create: `app/Console/Commands/NotificationSweep.php`
- Modify: `app/Console/Kernel.php`
- Test: `tests/Feature/Commands/NotificationSweepTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Feature/Commands/NotificationSweepTest.php

namespace Tests\Feature\Commands;

use TagInsight\Commons\Models\Notification;
use TagInsight\Commons\Models\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class NotificationSweepTest extends TestCase
{
    use RefreshDatabase;

    public function test_archives_notifications_older_than_30_days(): void
    {
        $u = User::factory()->create();
        $old = Notification::factory()->create([
            'user_id' => $u->id,
            'created_at' => now()->subDays(31),
        ]);
        $fresh = Notification::factory()->create([
            'user_id' => $u->id,
            'created_at' => now()->subDays(5),
        ]);

        $this->artisan('notifications:sweep')->assertExitCode(0);

        $this->assertNotNull($old->fresh()->archived_at);
        $this->assertNull($fresh->fresh()->archived_at);
    }

    public function test_deletes_archived_notifications_older_than_90_days(): void
    {
        $u = User::factory()->create();
        $purge = Notification::factory()->create([
            'user_id' => $u->id,
            'archived_at' => now()->subDays(91),
        ]);
        $keep = Notification::factory()->create([
            'user_id' => $u->id,
            'archived_at' => now()->subDays(30),
        ]);

        $this->artisan('notifications:sweep')->assertExitCode(0);

        $this->assertNull(Notification::find($purge->id));
        $this->assertNotNull(Notification::find($keep->id));
    }
}
```

- [ ] **Step 2: Run tests to verify they fail**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationSweepTest
```

Expected: FAIL.

- [ ] **Step 3: Implement the command**

```php
<?php
// app/Console/Commands/NotificationSweep.php

namespace App\Console\Commands;

use TagInsight\Commons\Models\Notification;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;

class NotificationSweep extends Command
{
    protected $signature = 'notifications:sweep';
    protected $description = 'Auto-archive notifications older than 30d and delete archived rows older than 90d';

    public function handle(): int
    {
        $archived = 0;
        Notification::whereNull('archived_at')
            ->where('created_at', '<', now()->subDays(30))
            ->chunkById(1000, function ($chunk) use (&$archived) {
                $ids = $chunk->pluck('id');
                $archived += Notification::whereIn('id', $ids)->update(['archived_at' => now()]);
            });

        $deleted = 0;
        Notification::whereNotNull('archived_at')
            ->where('archived_at', '<', now()->subDays(90))
            ->chunkById(1000, function ($chunk) use (&$deleted) {
                $ids = $chunk->pluck('id');
                $deleted += Notification::whereIn('id', $ids)->delete();
            });

        Log::info('notifications.sweep', ['archived' => $archived, 'deleted' => $deleted]);
        $this->info("Archived {$archived}, deleted {$deleted}");

        return self::SUCCESS;
    }
}
```

- [ ] **Step 4: Schedule the command**

In `app/Console/Kernel.php`'s `schedule()`:

```php
$schedule->command('notifications:sweep')->dailyAt('03:00')->timezone('Europe/Paris');
```

- [ ] **Step 5: Run tests to verify they pass**

```bash
docker compose exec saas-core-api php artisan test --filter=NotificationSweepTest
```

Expected: PASS, 2 tests.

- [ ] **Step 6: Commit**

```bash
git add app/Console/Commands/NotificationSweep.php app/Console/Kernel.php tests/Feature/Commands/NotificationSweepTest.php
git commit -m "feat(notifications): daily retention sweep command"
```

---

## Task B11: Push core-api branch + open PR

- [ ] **Step 1: Push branch**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-core-api
git push origin feature/notifications-phase1
```

- [ ] **Step 2: Open PR**

Open PR `saas-core-api` -> `dev`, reviewers @nyfen + @joel. Title: `feat(notifications): Phase 1 backend (REST + job + retention)`. Link to spec.

---

# Phase C: Event emitters (origin APIs)

> Phase C requires Phase A (commons released) and Phase B's `SendNotificationJob` class loaded. Confirm the cross-API dispatch mechanism via PF-1 first.

## Task C1: GTM Monitor emitter

**Files:**
- Create: `saas-tag-management-api/app/Services/Notifications/NotificationEmitter.php`
- Modify: the call site in `saas-tag-management-api/app/GtmMonitor/` where `GtmPublishedVersion` is saved with status `NEW_VERSION` (locate via `grep -rn "NEW_VERSION" app/GtmMonitor/`)
- Test: `saas-tag-management-api/tests/Feature/Notifications/GtmNewVersionEmissionTest.php`

- [ ] **Step 1: Locate the call site**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-tag-management-api
grep -rn "NEW_VERSION\|GtmPublishedVersion" app/GtmMonitor/
```

Note the file and line where the new version is recorded. Use that as the insertion point in Step 4.

- [ ] **Step 2: Write the failing test**

```php
<?php
// tests/Feature/Notifications/GtmNewVersionEmissionTest.php

namespace Tests\Feature\Notifications;

use App\Services\Notifications\NotificationEmitter;
use App\Jobs\SendNotificationJob;  // class from core-api, loaded via shared autoload
use TagInsight\Commons\Models\Client;
use TagInsight\Commons\Models\Project;
use Tests\TestCase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Foundation\Testing\RefreshDatabase;

class GtmNewVersionEmissionTest extends TestCase
{
    use RefreshDatabase;

    public function test_dispatches_send_notification_job_with_correct_payload(): void
    {
        Queue::fake();

        $client = Client::factory()->create();
        $project = Project::factory()->create(['client_id' => $client->id, 'name' => 'Acme']);

        // Replace this with the actual factory + minimal fields used in your repo
        $publishedVersion = new class {
            public string $id = 'pv-uuid';
            public string $project_id;
            public string $client_id;
            public string $container_name = 'GTM-XYZ';
            public int $version_number = 42;
        };
        $publishedVersion->project_id = $project->id;
        $publishedVersion->client_id = $client->id;

        (new NotificationEmitter())->gtmNewVersion($publishedVersion);

        Queue::assertPushed(SendNotificationJob::class, function ($job) use ($client, $project) {
            return $job->type === 'gtm.new_version'
                && $job->clientId === $client->id
                && $job->projectId === $project->id
                && $job->title !== ''
                && isset($job->payload['published_version_id']);
        });
    }
}
```

> If `GtmPublishedVersion` has a factory, prefer it over the anonymous class. The shape shown is illustrative; adapt to the real model fields.

- [ ] **Step 3: Run test to verify it fails**

```bash
docker compose exec saas-tag-management-api php artisan test --filter=GtmNewVersionEmissionTest
```

Expected: FAIL, "Class NotificationEmitter does not exist".

- [ ] **Step 4: Implement the emitter**

```php
<?php
// app/Services/Notifications/NotificationEmitter.php

namespace App\Services\Notifications;

use App\Jobs\SendNotificationJob;

class NotificationEmitter
{
    public function gtmNewVersion(object $publishedVersion): void
    {
        SendNotificationJob::dispatch(
            type: 'gtm.new_version',
            clientId: $publishedVersion->client_id,
            projectId: $publishedVersion->project_id,
            title: "New GTM container version: {$publishedVersion->container_name} v{$publishedVersion->version_number}",
            body: "A new version of the GTM container was published. Review the changes in the GTM Monitor.",
            payload: [
                'published_version_id' => $publishedVersion->id,
                'container_name' => $publishedVersion->container_name,
                'version_number' => $publishedVersion->version_number,
            ],
            actionUrl: "/projects/{$publishedVersion->project_id}/gtm-monitor?version={$publishedVersion->id}",
        );
    }
}
```

> Adjust the field accessors (`container_name`, `version_number`) to match the actual `GtmPublishedVersion` model.

- [ ] **Step 5: Call the emitter from the GTM Monitor poller**

In the file located in Step 1 (likely `app/GtmMonitor/GtmMonitorPoller.php`), after a published version is saved with status `NEW_VERSION`, add:

```php
use App\Services\Notifications\NotificationEmitter;

// ... after $publishedVersion->save() when status === NEW_VERSION ...
app(NotificationEmitter::class)->gtmNewVersion($publishedVersion);
```

- [ ] **Step 6: Run tests to verify they pass**

```bash
docker compose exec saas-tag-management-api php artisan test --filter=GtmNewVersionEmissionTest
```

Expected: PASS.

- [ ] **Step 7: Commit + push**

```bash
git add app/Services/Notifications/NotificationEmitter.php app/GtmMonitor/ tests/Feature/Notifications/GtmNewVersionEmissionTest.php
git commit -m "feat(notifications): emit gtm.new_version on container version detection"
git push origin feature/notifications-phase1
```

Open PR `saas-tag-management-api` -> `dev`, reviewers @nyfen + @joel.

---

## Task C2: QA audit-finished emitter

**Files:**
- Create: `saas-qa-datalayer-api/app/Services/Notifications/NotificationEmitter.php`
- Modify: the QAJob state-machine transition file (locate via `grep -rn "JobStatusEvent\|delayDispatchJobStatusEvent" app/`)
- Test: `saas-qa-datalayer-api/tests/Feature/Notifications/QaAuditFinishedEmissionTest.php`

- [ ] **Step 1: Locate the call site**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-qa-datalayer-api
grep -rn "delayDispatchJobStatusEvent\|JobStatusEvent" app/ | head -20
```

Note the file/line where the QAJob enters a terminal success state.

- [ ] **Step 2: Write the failing test**

```php
<?php
// tests/Feature/Notifications/QaAuditFinishedEmissionTest.php

namespace Tests\Feature\Notifications;

use App\Services\Notifications\NotificationEmitter;
use App\Jobs\SendNotificationJob;
use TagInsight\Commons\Models\Client;
use TagInsight\Commons\Models\Project;
use Tests\TestCase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Foundation\Testing\RefreshDatabase;

class QaAuditFinishedEmissionTest extends TestCase
{
    use RefreshDatabase;

    public function test_dispatches_send_notification_job(): void
    {
        Queue::fake();

        $client = Client::factory()->create();
        $project = Project::factory()->create(['client_id' => $client->id]);

        $qaJob = new class {
            public string $id = 'qa-uuid';
            public string $project_id;
            public string $client_id;
            public int $passed = 18;
            public int $total = 20;
            public int $errors_found = 2;
        };
        $qaJob->project_id = $project->id;
        $qaJob->client_id = $client->id;

        (new NotificationEmitter())->qaAuditFinished($qaJob);

        Queue::assertPushed(SendNotificationJob::class, fn ($job) =>
            $job->type === 'qa.audit_finished'
            && $job->clientId === $client->id
            && $job->projectId === $project->id
            && isset($job->payload['qa_job_id'])
            && $job->payload['passed'] === 18
        );
    }
}
```

- [ ] **Step 3: Run test to verify it fails**

```bash
docker compose exec saas-qa-datalayer-api php artisan test --filter=QaAuditFinishedEmissionTest
```

Expected: FAIL.

- [ ] **Step 4: Implement the emitter**

```php
<?php
// app/Services/Notifications/NotificationEmitter.php

namespace App\Services\Notifications;

use App\Jobs\SendNotificationJob;

class NotificationEmitter
{
    public function qaAuditFinished(object $qaJob): void
    {
        $title = $qaJob->errors_found > 0
            ? "QA audit finished with {$qaJob->errors_found} error(s)"
            : "QA audit finished successfully";

        SendNotificationJob::dispatch(
            type: 'qa.audit_finished',
            clientId: $qaJob->client_id,
            projectId: $qaJob->project_id,
            title: $title,
            body: "{$qaJob->passed}/{$qaJob->total} checks passed. See full results in the QA Datalayer module.",
            payload: [
                'qa_job_id' => $qaJob->id,
                'passed' => $qaJob->passed,
                'total' => $qaJob->total,
                'errors_found' => $qaJob->errors_found,
            ],
            actionUrl: "/projects/{$qaJob->project_id}/qa-datalayer/jobs/{$qaJob->id}",
        );
    }

    public function qaAuditFailed(object $qaJob, string $reason): void
    {
        SendNotificationJob::dispatch(
            type: 'qa.audit_failed',
            clientId: $qaJob->client_id,
            projectId: $qaJob->project_id,
            title: "QA audit failed to complete",
            body: "Reason: {$reason}",
            payload: [
                'qa_job_id' => $qaJob->id,
                'reason' => $reason,
            ],
            actionUrl: "/projects/{$qaJob->project_id}/qa-datalayer/jobs/{$qaJob->id}",
        );
    }
}
```

- [ ] **Step 5: Call the emitter from the QA job state machine**

In the file located in Step 1, after the QAJob transitions to a successful terminal state:

```php
use App\Services\Notifications\NotificationEmitter;

app(NotificationEmitter::class)->qaAuditFinished($qaJob);
```

- [ ] **Step 6: Run tests to verify they pass**

```bash
docker compose exec saas-qa-datalayer-api php artisan test --filter=QaAuditFinishedEmissionTest
```

Expected: PASS.

- [ ] **Step 7: Commit**

```bash
git add app/Services/Notifications/NotificationEmitter.php app/ tests/Feature/Notifications/QaAuditFinishedEmissionTest.php
git commit -m "feat(notifications): emit qa.audit_finished on terminal success"
```

---

## Task C3: QA audit-failed emitter

**Files:**
- Modify: the QAJob state-machine transition file for failed terminal states
- Test: `saas-qa-datalayer-api/tests/Feature/Notifications/QaAuditFailedEmissionTest.php`

- [ ] **Step 1: Write the failing test**

```php
<?php
// tests/Feature/Notifications/QaAuditFailedEmissionTest.php

namespace Tests\Feature\Notifications;

use App\Services\Notifications\NotificationEmitter;
use App\Jobs\SendNotificationJob;
use TagInsight\Commons\Models\Client;
use TagInsight\Commons\Models\Project;
use Tests\TestCase;
use Illuminate\Support\Facades\Queue;
use Illuminate\Foundation\Testing\RefreshDatabase;

class QaAuditFailedEmissionTest extends TestCase
{
    use RefreshDatabase;

    public function test_dispatches_failure_notification(): void
    {
        Queue::fake();

        $client = Client::factory()->create();
        $project = Project::factory()->create(['client_id' => $client->id]);

        $qaJob = new class {
            public string $id = 'qa-uuid';
            public string $project_id;
            public string $client_id;
        };
        $qaJob->project_id = $project->id;
        $qaJob->client_id = $client->id;

        (new NotificationEmitter())->qaAuditFailed($qaJob, 'timeout after 30 minutes');

        Queue::assertPushed(SendNotificationJob::class, fn ($job) =>
            $job->type === 'qa.audit_failed'
            && $job->payload['reason'] === 'timeout after 30 minutes'
        );
    }
}
```

- [ ] **Step 2: Run test to verify it passes (emitter already implemented in C2)**

```bash
docker compose exec saas-qa-datalayer-api php artisan test --filter=QaAuditFailedEmissionTest
```

Expected: PASS.

- [ ] **Step 3: Wire the call into the failure transition**

In the same file as C2 Step 1, after the QAJob transitions to a failed terminal state:

```php
app(NotificationEmitter::class)->qaAuditFailed($qaJob, $qaJob->error_message ?? 'unknown error');
```

- [ ] **Step 4: Commit + push**

```bash
git add app/ tests/Feature/Notifications/QaAuditFailedEmissionTest.php
git commit -m "feat(notifications): emit qa.audit_failed on terminal failure"
git push origin feature/notifications-phase1
```

Open PR `saas-qa-datalayer-api` -> `dev`, reviewers @nyfen + @joel.

---

# Phase D: Frontend

> Phase D requires Phase B merged + deployed (so the REST endpoints exist), and the new `commons` TS types published.

## Task D1: Update commons dependency in frontend

- [ ] **Step 1: Bump commons package version**

In `saas-front-end/package.json`, bump the version of the `commons` (or `@taginsight/commons`) dependency to the version tagged in Task A5.

- [ ] **Step 2: Install**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-front-end
yarn install
```

- [ ] **Step 3: Verify types import**

```bash
yarn type-check
```

Expected: no errors. If the build cannot find `Notification` from commons, check the barrel export in Task A4.

- [ ] **Step 4: Commit**

```bash
git checkout -b feature/notifications-phase1
git add package.json yarn.lock
git commit -m "chore: bump commons for notification types"
```

---

## Task D2: Notification service (REST wrapper)

**Files:**
- Create: `saas-front-end/src/services/notificationService.ts`

- [ ] **Step 1: Implement the service**

```ts
// saas-front-end/src/services/notificationService.ts

import { coreAxiosInstance } from '@/api/core';
import type {
  Notification,
  NotificationPreference,
  UnreadCountResponse,
  PaginatedNotifications,
} from 'commons';

export interface InboxFilters {
  client_id: string;
  archived?: boolean;
  read?: 'true' | 'false';
  type?: string[];
  project_id?: string[];
  cursor?: string | null;
  per_page?: number;
}

export const notificationService = {
  fetchInbox(filters: InboxFilters): Promise<PaginatedNotifications> {
    return coreAxiosInstance
      .get('/notifications', { params: filters })
      .then((r) => ({
        data: r.data.data as Notification[],
        next_cursor: (r.data.next_cursor ?? null) as string | null,
      }));
  },

  fetchUnreadCount(): Promise<UnreadCountResponse> {
    return coreAxiosInstance
      .get<UnreadCountResponse>('/notifications/unread-count')
      .then((r) => r.data);
  },

  markRead(id: string): Promise<void> {
    return coreAxiosInstance.post(`/notifications/${id}/read`).then(() => undefined);
  },

  markUnread(id: string): Promise<void> {
    return coreAxiosInstance.post(`/notifications/${id}/unread`).then(() => undefined);
  },

  markAllRead(filters: { client_id?: string; type?: string[] }): Promise<void> {
    return coreAxiosInstance.post('/notifications/mark-all-read', filters).then(() => undefined);
  },

  archive(id: string): Promise<void> {
    return coreAxiosInstance.post(`/notifications/${id}/archive`).then(() => undefined);
  },

  bulkArchive(ids: string[]): Promise<void> {
    return coreAxiosInstance.post('/notifications/bulk-archive', { ids }).then(() => undefined);
  },

  fetchPreferences(): Promise<{ preferences: NotificationPreference[] }> {
    return coreAxiosInstance
      .get('/notification-preferences')
      .then((r) => r.data);
  },

  savePreferences(preferences: NotificationPreference[]): Promise<void> {
    return coreAxiosInstance.put('/notification-preferences', { preferences }).then(() => undefined);
  },
};
```

- [ ] **Step 2: Type-check**

```bash
yarn type-check
```

Expected: PASS.

- [ ] **Step 3: Commit**

```bash
git add src/services/notificationService.ts
git commit -m "feat(notifications): add REST service wrapper"
```

---

## Task D3: Pinia store with polling

**Files:**
- Create: `saas-front-end/src/stores/notification.ts`
- Test: `saas-front-end/src/stores/__tests__/notification.spec.ts`

- [ ] **Step 1: Write the failing test**

```ts
// saas-front-end/src/stores/__tests__/notification.spec.ts

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useNotificationStore } from '@/stores/notification';
import { notificationService } from '@/services/notificationService';

vi.mock('@/services/notificationService', () => ({
  notificationService: {
    fetchUnreadCount: vi.fn(),
    fetchInbox: vi.fn(),
    markRead: vi.fn(),
    markAllRead: vi.fn(),
    archive: vi.fn(),
    bulkArchive: vi.fn(),
    fetchPreferences: vi.fn(),
    savePreferences: vi.fn(),
  },
}));

describe('notification store', () => {
  beforeEach(() => {
    setActivePinia(createPinia());
    vi.useFakeTimers();
    vi.clearAllMocks();
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('refetchUnreadCount populates count and by_client', async () => {
    (notificationService.fetchUnreadCount as any).mockResolvedValue({
      count: 5,
      by_client: { 'c1': 3, 'c2': 2 },
    });

    const store = useNotificationStore();
    await store.refetchUnreadCount();

    expect(store.unreadCount).toBe(5);
    expect(store.unreadByClient).toEqual({ c1: 3, c2: 2 });
  });

  it('startPolling triggers refetch every 30 seconds', async () => {
    (notificationService.fetchUnreadCount as any).mockResolvedValue({
      count: 0,
      by_client: {},
    });

    const store = useNotificationStore();
    store.startPolling();

    expect(notificationService.fetchUnreadCount).toHaveBeenCalledTimes(1);

    await vi.advanceTimersByTimeAsync(30_000);
    expect(notificationService.fetchUnreadCount).toHaveBeenCalledTimes(2);

    await vi.advanceTimersByTimeAsync(30_000);
    expect(notificationService.fetchUnreadCount).toHaveBeenCalledTimes(3);

    store.stopPolling();
  });

  it('markRead decrements unreadCount and updates by_client', async () => {
    (notificationService.fetchUnreadCount as any).mockResolvedValue({
      count: 3,
      by_client: { c1: 3 },
    });
    (notificationService.markRead as any).mockResolvedValue(undefined);

    const store = useNotificationStore();
    await store.refetchUnreadCount();

    store.drawerItems = [
      { id: 'n1', client_id: 'c1', read_at: null } as any,
    ];

    await store.markRead('n1');

    expect(notificationService.markRead).toHaveBeenCalledWith('n1');
    expect(store.unreadCount).toBe(2);
    expect(store.unreadByClient['c1']).toBe(2);
    expect(store.drawerItems[0].read_at).not.toBeNull();
  });
});
```

- [ ] **Step 2: Run test to verify it fails**

```bash
yarn test src/stores/__tests__/notification.spec.ts
```

Expected: FAIL, "Cannot find module '@/stores/notification'".

- [ ] **Step 3: Implement the store**

```ts
// saas-front-end/src/stores/notification.ts

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type {
  Notification,
  NotificationPreference,
  UnreadCountResponse,
} from 'commons';
import { notificationService, type InboxFilters } from '@/services/notificationService';

const POLL_INTERVAL_MS = 30_000;
const SHOW_ALL_CLIENTS_KEY = 'notifications.showAllClients';

export const useNotificationStore = defineStore('notification', () => {
  const unreadCount = ref(0);
  const unreadByClient = ref<Record<string, number>>({});
  const drawerItems = ref<Notification[]>([]);
  const drawerLoading = ref(false);
  const preferences = ref<NotificationPreference[]>([]);
  const showAllClients = ref<boolean>(
    localStorage.getItem(SHOW_ALL_CLIENTS_KEY) === '1'
  );

  let pollIntervalId: number | null = null;
  let visibilityHandler: (() => void) | null = null;

  async function refetchUnreadCount(): Promise<void> {
    try {
      const resp: UnreadCountResponse = await notificationService.fetchUnreadCount();
      unreadCount.value = resp.count;
      unreadByClient.value = resp.by_client;
    } catch {
      // interceptor handles user-facing errors; swallow here to keep polling alive
    }
  }

  function startPolling(): void {
    if (pollIntervalId !== null) return;

    refetchUnreadCount();

    pollIntervalId = window.setInterval(() => {
      refetchUnreadCount();
    }, POLL_INTERVAL_MS);

    visibilityHandler = () => {
      if (document.visibilityState === 'visible') {
        refetchUnreadCount();
      }
    };
    document.addEventListener('visibilitychange', visibilityHandler);
  }

  function stopPolling(): void {
    if (pollIntervalId !== null) {
      window.clearInterval(pollIntervalId);
      pollIntervalId = null;
    }
    if (visibilityHandler) {
      document.removeEventListener('visibilitychange', visibilityHandler);
      visibilityHandler = null;
    }
    unreadCount.value = 0;
    unreadByClient.value = {};
    drawerItems.value = [];
  }

  async function fetchDrawer(clientId: string): Promise<void> {
    drawerLoading.value = true;
    try {
      const page = await notificationService.fetchInbox({
        client_id: showAllClients.value ? 'all' : clientId,
        per_page: 20,
      });
      drawerItems.value = page.data;
    } finally {
      drawerLoading.value = false;
    }
    await refetchUnreadCount();
  }

  async function fetchInbox(filters: InboxFilters) {
    return notificationService.fetchInbox(filters);
  }

  async function markRead(id: string): Promise<void> {
    const item = drawerItems.value.find((n) => n.id === id);
    if (item && item.read_at !== null) return;

    await notificationService.markRead(id);

    if (item) {
      item.read_at = new Date().toISOString();
      unreadCount.value = Math.max(0, unreadCount.value - 1);
      if (item.client_id in unreadByClient.value) {
        unreadByClient.value[item.client_id] = Math.max(
          0,
          unreadByClient.value[item.client_id] - 1
        );
      }
    } else {
      await refetchUnreadCount();
    }
  }

  async function markAllRead(clientId?: string): Promise<void> {
    await notificationService.markAllRead(clientId ? { client_id: clientId } : {});
    await refetchUnreadCount();
    drawerItems.value = drawerItems.value.map((n) => ({
      ...n,
      read_at: n.read_at ?? new Date().toISOString(),
    }));
  }

  async function archive(id: string): Promise<void> {
    await notificationService.archive(id);
    drawerItems.value = drawerItems.value.filter((n) => n.id !== id);
    await refetchUnreadCount();
  }

  async function bulkArchive(ids: string[]): Promise<void> {
    await notificationService.bulkArchive(ids);
    drawerItems.value = drawerItems.value.filter((n) => !ids.includes(n.id));
    await refetchUnreadCount();
  }

  async function fetchPreferences(): Promise<void> {
    const resp = await notificationService.fetchPreferences();
    preferences.value = resp.preferences;
  }

  async function savePreferences(prefs: NotificationPreference[]): Promise<void> {
    await notificationService.savePreferences(prefs);
    preferences.value = prefs;
  }

  function setShowAllClients(value: boolean): void {
    showAllClients.value = value;
    localStorage.setItem(SHOW_ALL_CLIENTS_KEY, value ? '1' : '0');
  }

  return {
    // state
    unreadCount: computed(() => unreadCount.value),
    unreadByClient: computed(() => unreadByClient.value),
    drawerItems,
    drawerLoading: computed(() => drawerLoading.value),
    preferences,
    showAllClients,
    // actions
    startPolling,
    stopPolling,
    refetchUnreadCount,
    fetchDrawer,
    fetchInbox,
    markRead,
    markAllRead,
    archive,
    bulkArchive,
    fetchPreferences,
    savePreferences,
    setShowAllClients,
  };
});
```

- [ ] **Step 4: Run test to verify it passes**

```bash
yarn test src/stores/__tests__/notification.spec.ts
```

Expected: PASS, 3 tests.

- [ ] **Step 5: Commit**

```bash
git add src/stores/notification.ts src/stores/__tests__/notification.spec.ts
git commit -m "feat(notifications): Pinia store with polling + drawer state"
```

---

## Task D4: Start/stop polling lifecycle in `App.vue`

**Files:**
- Modify: `saas-front-end/src/App.vue`

- [ ] **Step 1: Wire startPolling to auth state**

In `App.vue`'s `<script setup>`, add:

```ts
import { watch, onMounted, onBeforeUnmount } from 'vue';
import { useTokenStore } from '@/stores/token';
import { useNotificationStore } from '@/stores/notification';

const tokenStore = useTokenStore();
const notificationStore = useNotificationStore();

function syncPolling() {
  if (tokenStore.tokenData?.token) {
    notificationStore.startPolling();
  } else {
    notificationStore.stopPolling();
  }
}

onMounted(syncPolling);
watch(() => tokenStore.tokenData?.token, syncPolling);
onBeforeUnmount(() => notificationStore.stopPolling());
```

> Adjust `useTokenStore` import path / shape to match the actual codebase.

- [ ] **Step 2: Type-check + lint**

```bash
yarn type-check && yarn lint
```

Expected: clean.

- [ ] **Step 3: Commit**

```bash
git add src/App.vue
git commit -m "feat(notifications): start/stop polling on login/logout"
```

---

## Task D5: `NotificationRow` shared component

**Files:**
- Create: `saas-front-end/src/components/notifications/NotificationRow.vue`

- [ ] **Step 1: Implement the component**

```vue
<!-- saas-front-end/src/components/notifications/NotificationRow.vue -->
<script setup lang="ts">
import { computed } from 'vue';
import type { Notification } from 'commons';

const props = defineProps<{ notification: Notification }>();
const emit = defineEmits<{
  (e: 'mark-read', id: string): void;
  (e: 'archive', id: string): void;
  (e: 'open', notification: Notification): void;
}>();

const iconForType = computed(() => {
  switch (props.notification.type) {
    case 'gtm.new_version': return 'mdi-tag-multiple';
    case 'qa.audit_finished': return 'mdi-check-circle';
    case 'qa.audit_failed': return 'mdi-alert-circle';
    default: return 'mdi-bell';
  }
});

const colorForType = computed(() => {
  switch (props.notification.type) {
    case 'gtm.new_version': return 'info';
    case 'qa.audit_finished': return 'success';
    case 'qa.audit_failed': return 'error';
    default: return 'grey';
  }
});

const relativeTime = computed(() => {
  const diffMs = Date.now() - new Date(props.notification.created_at).getTime();
  const mins = Math.floor(diffMs / 60_000);
  if (mins < 1) return 'just now';
  if (mins < 60) return `${mins}m ago`;
  const hours = Math.floor(mins / 60);
  if (hours < 24) return `${hours}h ago`;
  return `${Math.floor(hours / 24)}d ago`;
});

const isUnread = computed(() => props.notification.read_at === null);

function onClickOpen() {
  emit('open', props.notification);
  if (isUnread.value) emit('mark-read', props.notification.id);
}
</script>

<template>
  <div
    class="notification-row"
    :class="{ 'notification-row--unread': isUnread }"
    @click="onClickOpen"
  >
    <div class="notification-row__icon" :data-color="colorForType">
      <i :class="iconForType"></i>
    </div>
    <div class="notification-row__body">
      <div class="notification-row__title">{{ notification.title }}</div>
      <div class="notification-row__text">{{ notification.body }}</div>
      <div class="notification-row__meta">{{ relativeTime }}</div>
    </div>
    <div class="notification-row__actions">
      <button
        v-if="isUnread"
        @click.stop="emit('mark-read', notification.id)"
        :title="$t('notifications.markRead')"
      >
        <i class="mdi-check"></i>
      </button>
      <button
        @click.stop="emit('archive', notification.id)"
        :title="$t('notifications.archive')"
      >
        <i class="mdi-archive"></i>
      </button>
    </div>
  </div>
</template>

<style scoped lang="scss">
.notification-row {
  display: flex;
  gap: 12px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--color-border);
  cursor: pointer;

  &--unread {
    background-color: var(--color-bg-highlight);
    .notification-row__title { font-weight: 600; }
  }

  &__icon {
    flex-shrink: 0;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;

    &[data-color="info"]    { background: var(--color-info); }
    &[data-color="success"] { background: var(--color-success); }
    &[data-color="error"]   { background: var(--color-error); }
    &[data-color="grey"]    { background: var(--color-grey); }
  }

  &__body { flex: 1; min-width: 0; }
  &__title { font-size: 14px; margin-bottom: 2px; }
  &__text  { font-size: 13px; color: var(--color-text-secondary); }
  &__meta  { font-size: 12px; color: var(--color-text-tertiary); margin-top: 4px; }

  &__actions {
    display: flex;
    gap: 4px;
    opacity: 0;
    transition: opacity 0.15s;
    align-self: center;
  }
  &:hover &__actions { opacity: 1; }
}
</style>
```

> If the project uses Vuetify, Quasar, or a custom design system, swap the raw HTML for the project's component primitives. Inspect a similar list component in `src/components/` first.

- [ ] **Step 2: Type-check**

```bash
yarn type-check
```

Expected: clean.

- [ ] **Step 3: Commit**

```bash
git add src/components/notifications/NotificationRow.vue
git commit -m "feat(notifications): NotificationRow component"
```

---

## Task D6: `NotificationBell` + `NotificationDrawer`

**Files:**
- Create: `saas-front-end/src/components/notifications/NotificationBell.vue`
- Create: `saas-front-end/src/components/notifications/NotificationDrawer.vue`
- Modify: the existing top-nav component (find via `grep -rn "user-menu\|UserMenu\|topbar" src/components/` and pick the right one)

- [ ] **Step 1: Implement the drawer**

```vue
<!-- saas-front-end/src/components/notifications/NotificationDrawer.vue -->
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useNotificationStore } from '@/stores/notification';
import { useClientStore } from '@/stores/client'; // adjust to actual store
import NotificationRow from './NotificationRow.vue';
import type { Notification } from 'commons';

const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();

const store = useNotificationStore();
const clientStore = useClientStore();
const router = useRouter();

const tab = ref<'unread' | 'all'>('unread');

const visibleItems = computed(() =>
  tab.value === 'unread'
    ? store.drawerItems.filter((n) => n.read_at === null)
    : store.drawerItems
);

watch(
  () => props.open,
  async (open) => {
    if (open) {
      await store.fetchDrawer(clientStore.activeClientId);
    }
  }
);

function open(notification: Notification) {
  emit('close');
  if (notification.action_url) router.push(notification.action_url);
}

function viewAll() {
  emit('close');
  router.push('/notifications');
}

function openPreferences() {
  emit('close');
  router.push('/notifications/preferences');
}
</script>

<template>
  <transition name="slide-right">
    <aside v-if="open" class="notif-drawer" @click.self="emit('close')">
      <div class="notif-drawer__panel">
        <header class="notif-drawer__header">
          <h2>{{ $t('notifications.title') }}</h2>
          <div class="notif-drawer__header-actions">
            <button @click="store.markAllRead(clientStore.activeClientId)">
              {{ $t('notifications.markAllRead') }}
            </button>
            <button @click="openPreferences">
              <i class="mdi-cog"></i>
            </button>
            <button @click="emit('close')">
              <i class="mdi-close"></i>
            </button>
          </div>
        </header>

        <div class="notif-drawer__tabs">
          <button :class="{ active: tab === 'unread' }" @click="tab = 'unread'">
            {{ $t('notifications.tabs.unread') }} ({{ store.unreadCount }})
          </button>
          <button :class="{ active: tab === 'all' }" @click="tab = 'all'">
            {{ $t('notifications.tabs.all') }}
          </button>
        </div>

        <div class="notif-drawer__list">
          <div v-if="store.drawerLoading" class="notif-drawer__loading">
            {{ $t('common.loading') }}
          </div>
          <div v-else-if="visibleItems.length === 0" class="notif-drawer__empty">
            {{ tab === 'unread' ? $t('notifications.empty.unread') : $t('notifications.empty.all') }}
          </div>
          <NotificationRow
            v-for="n in visibleItems"
            :key="n.id"
            :notification="n"
            @mark-read="store.markRead"
            @archive="store.archive"
            @open="open"
          />
        </div>

        <footer class="notif-drawer__footer">
          <button @click="viewAll">{{ $t('notifications.viewAll') }} &rarr;</button>
        </footer>
      </div>
    </aside>
  </transition>
</template>

<style scoped lang="scss">
.notif-drawer {
  position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 1000;

  &__panel {
    position: absolute; top: 0; right: 0; bottom: 0; width: 400px;
    background: var(--color-bg-elevated); display: flex; flex-direction: column;
  }
  &__header { display: flex; justify-content: space-between; align-items: center; padding: 16px; border-bottom: 1px solid var(--color-border); }
  &__header-actions { display: flex; gap: 8px; }
  &__tabs { display: flex; border-bottom: 1px solid var(--color-border);
    button { flex: 1; padding: 12px; background: transparent; border: 0; cursor: pointer;
      &.active { border-bottom: 2px solid var(--color-primary); color: var(--color-primary); }
    }
  }
  &__list { flex: 1; overflow-y: auto; }
  &__loading, &__empty { padding: 24px; text-align: center; color: var(--color-text-secondary); }
  &__footer { padding: 12px 16px; border-top: 1px solid var(--color-border); text-align: center; }
}

.slide-right-enter-active, .slide-right-leave-active { transition: transform 0.2s; }
.slide-right-enter-from, .slide-right-leave-to { transform: translateX(100%); }
</style>
```

- [ ] **Step 2: Implement the bell**

```vue
<!-- saas-front-end/src/components/notifications/NotificationBell.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useNotificationStore } from '@/stores/notification';
import { useClientStore } from '@/stores/client'; // adjust
import NotificationDrawer from './NotificationDrawer.vue';

const store = useNotificationStore();
const clientStore = useClientStore();

const drawerOpen = ref(false);

const badge = computed(() => {
  const n = store.showAllClients
    ? store.unreadCount
    : (store.unreadByClient[clientStore.activeClientId] ?? 0);
  if (n === 0) return null;
  if (n > 9) return '9+';
  return String(n);
});

function toggle() {
  drawerOpen.value = !drawerOpen.value;
}
</script>

<template>
  <div class="notif-bell">
    <button class="notif-bell__button" @click="toggle" :aria-label="$t('notifications.title')">
      <i class="mdi-bell"></i>
      <span v-if="badge" class="notif-bell__badge">{{ badge }}</span>
    </button>
    <NotificationDrawer :open="drawerOpen" @close="drawerOpen = false" />
  </div>
</template>

<style scoped lang="scss">
.notif-bell { position: relative;
  &__button { background: transparent; border: 0; cursor: pointer; padding: 8px; position: relative;
    i { font-size: 20px; }
  }
  &__badge { position: absolute; top: 4px; right: 4px; background: var(--color-error);
    color: white; font-size: 10px; padding: 2px 5px; border-radius: 10px; min-width: 16px; text-align: center; }
}
</style>
```

- [ ] **Step 3: Mount the bell in the top nav**

Find the top nav file (e.g., `src/components/layout/TopBar.vue`) and add `<NotificationBell />` adjacent to the user menu:

```vue
<script setup lang="ts">
import NotificationBell from '@/components/notifications/NotificationBell.vue';
</script>

<template>
  <!-- ... existing nav items ... -->
  <NotificationBell />
  <!-- ... user menu ... -->
</template>
```

- [ ] **Step 4: Add i18n keys**

In each `src/locales/*.json`, add under a top-level `notifications` key:

```json
{
  "notifications": {
    "title": "Notifications",
    "markRead": "Mark as read",
    "markAllRead": "Mark all as read",
    "archive": "Archive",
    "viewAll": "View all notifications",
    "tabs": { "unread": "Unread", "all": "All" },
    "empty": {
      "unread": "You're all caught up.",
      "all": "No notifications yet."
    }
  }
}
```

Translate per locale.

- [ ] **Step 5: Type-check + lint**

```bash
yarn type-check && yarn lint
```

Expected: clean.

- [ ] **Step 6: Commit**

```bash
git add src/components/notifications/ src/components/layout/ src/locales/
git commit -m "feat(notifications): bell + drawer in global nav"
```

---

## Task D7: Routes for dashboard + preferences

**Files:**
- Modify: `saas-front-end/src/router/index.ts` (or wherever routes are declared)

- [ ] **Step 1: Add the routes**

```ts
{
  path: '/notifications',
  component: () => import('@/views/notifications/NotificationDashboard.vue'),
  meta: { requiresAuth: true },
},
{
  path: '/notifications/preferences',
  component: () => import('@/views/notifications/NotificationPreferences.vue'),
  meta: { requiresAuth: true },
},
```

> Use the same `meta` shape and routing pattern as other authenticated routes in this file.

- [ ] **Step 2: Commit**

```bash
git add src/router/
git commit -m "feat(notifications): add /notifications routes"
```

---

## Task D8: `NotificationDashboard` view (inbox + widgets)

**Files:**
- Create: `saas-front-end/src/views/notifications/NotificationDashboard.vue`

- [ ] **Step 1: Implement the dashboard view**

```vue
<!-- saas-front-end/src/views/notifications/NotificationDashboard.vue -->
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useNotificationStore } from '@/stores/notification';
import { useClientStore } from '@/stores/client';
import NotificationRow from '@/components/notifications/NotificationRow.vue';
import type { Notification, NotificationType } from 'commons';

const store = useNotificationStore();
const clientStore = useClientStore();
const router = useRouter();

const inbox = ref<Notification[]>([]);
const nextCursor = ref<string | null>(null);
const loading = ref(false);
const selectedIds = ref<Set<string>>(new Set());

const typeFilter = ref<NotificationType[]>([]);
const projectFilter = ref<string[]>([]);
const readFilter = ref<'' | 'true' | 'false'>('');
const allClients = ref(store.showAllClients);

watch(allClients, (v) => store.setShowAllClients(v));

async function loadInbox(reset = true) {
  loading.value = true;
  try {
    const page = await store.fetchInbox({
      client_id: allClients.value ? 'all' : clientStore.activeClientId,
      cursor: reset ? null : nextCursor.value,
      per_page: 20,
      type: typeFilter.value.length ? typeFilter.value : undefined,
      project_id: projectFilter.value.length ? projectFilter.value : undefined,
      read: readFilter.value || undefined,
    });
    inbox.value = reset ? page.data : [...inbox.value, ...page.data];
    nextCursor.value = page.next_cursor;
  } finally {
    loading.value = false;
  }
}

onMounted(loadInbox);
watch([typeFilter, projectFilter, readFilter, allClients], () => loadInbox(true), { deep: true });

const gtmRecent = computed(() =>
  inbox.value.filter((n) => n.type === 'gtm.new_version').slice(0, 5)
);
const qaRecent = computed(() =>
  inbox.value.filter((n) => n.type === 'qa.audit_finished' || n.type === 'qa.audit_failed').slice(0, 5)
);

function toggleSelect(id: string) {
  if (selectedIds.value.has(id)) selectedIds.value.delete(id);
  else selectedIds.value.add(id);
  // trigger reactivity
  selectedIds.value = new Set(selectedIds.value);
}

async function bulkArchive() {
  await store.bulkArchive(Array.from(selectedIds.value));
  selectedIds.value.clear();
  await loadInbox(true);
}

async function bulkMarkRead() {
  for (const id of selectedIds.value) {
    await store.markRead(id);
  }
  selectedIds.value.clear();
  await loadInbox(true);
}

function open(notification: Notification) {
  if (notification.action_url) router.push(notification.action_url);
}
</script>

<template>
  <div class="notif-dashboard">
    <header class="notif-dashboard__header">
      <h1>{{ $t('notifications.title') }}</h1>
      <router-link to="/notifications/preferences">{{ $t('notifications.preferences') }}</router-link>
    </header>

    <div class="notif-dashboard__grid">
      <section class="notif-dashboard__inbox">
        <div class="notif-dashboard__filters">
          <label>
            <input type="checkbox" v-model="allClients" />
            {{ $t('notifications.allClients') }}
          </label>

          <select v-model="readFilter">
            <option value="">{{ $t('notifications.filter.allReadStates') }}</option>
            <option value="false">{{ $t('notifications.filter.unreadOnly') }}</option>
            <option value="true">{{ $t('notifications.filter.readOnly') }}</option>
          </select>

          <select multiple v-model="typeFilter">
            <option value="gtm.new_version">GTM new version</option>
            <option value="qa.audit_finished">QA audit finished</option>
            <option value="qa.audit_failed">QA audit failed</option>
          </select>
        </div>

        <div v-if="selectedIds.size > 0" class="notif-dashboard__bulk">
          <span>{{ selectedIds.size }} selected</span>
          <button @click="bulkMarkRead">{{ $t('notifications.markRead') }}</button>
          <button @click="bulkArchive">{{ $t('notifications.archive') }}</button>
        </div>

        <div class="notif-dashboard__list">
          <div v-for="n in inbox" :key="n.id" class="notif-dashboard__row">
            <input
              type="checkbox"
              :checked="selectedIds.has(n.id)"
              @change="toggleSelect(n.id)"
            />
            <NotificationRow
              :notification="n"
              @mark-read="store.markRead"
              @archive="(id) => { store.archive(id); loadInbox(true); }"
              @open="open"
            />
          </div>
          <div v-if="loading">{{ $t('common.loading') }}</div>
          <button v-else-if="nextCursor" @click="loadInbox(false)">
            {{ $t('notifications.loadMore') }}
          </button>
        </div>
      </section>

      <aside class="notif-dashboard__widgets">
        <div class="notif-dashboard__widget">
          <h3>{{ $t('notifications.widgets.gtm') }}</h3>
          <NotificationRow
            v-for="n in gtmRecent"
            :key="n.id"
            :notification="n"
            @mark-read="store.markRead"
            @archive="(id) => { store.archive(id); loadInbox(true); }"
            @open="open"
          />
          <div v-if="!gtmRecent.length" class="notif-dashboard__widget-empty">
            {{ $t('notifications.widgets.empty') }}
          </div>
        </div>

        <div class="notif-dashboard__widget">
          <h3>{{ $t('notifications.widgets.qa') }}</h3>
          <NotificationRow
            v-for="n in qaRecent"
            :key="n.id"
            :notification="n"
            @mark-read="store.markRead"
            @archive="(id) => { store.archive(id); loadInbox(true); }"
            @open="open"
          />
          <div v-if="!qaRecent.length" class="notif-dashboard__widget-empty">
            {{ $t('notifications.widgets.empty') }}
          </div>
        </div>
      </aside>
    </div>
  </div>
</template>

<style scoped lang="scss">
.notif-dashboard {
  padding: 24px; max-width: 1200px; margin: 0 auto;

  &__header { display: flex; justify-content: space-between; margin-bottom: 24px; }
  &__grid { display: grid; grid-template-columns: 2fr 1fr; gap: 24px;
    @media (max-width: 900px) { grid-template-columns: 1fr; }
  }
  &__inbox { background: var(--color-bg-elevated); border-radius: 8px; padding: 16px; }
  &__filters { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
  &__bulk { display: flex; gap: 12px; padding: 8px 12px; background: var(--color-bg-highlight); border-radius: 4px; margin-bottom: 12px; align-items: center; }
  &__row { display: flex; align-items: center; gap: 8px; }
  &__widgets { display: flex; flex-direction: column; gap: 16px; }
  &__widget { background: var(--color-bg-elevated); border-radius: 8px; padding: 16px;
    h3 { margin: 0 0 12px; font-size: 14px; }
  }
  &__widget-empty { color: var(--color-text-secondary); font-size: 13px; padding: 8px 0; }
}
</style>
```

- [ ] **Step 2: Add the new i18n keys**

```json
{
  "notifications": {
    "preferences": "Preferences",
    "allClients": "Show all clients",
    "loadMore": "Load more",
    "filter": {
      "allReadStates": "All",
      "unreadOnly": "Unread only",
      "readOnly": "Read only"
    },
    "widgets": {
      "gtm": "Latest GTM version changes",
      "qa": "Recent QA audit results",
      "empty": "Nothing yet."
    }
  }
}
```

- [ ] **Step 3: Type-check + lint + visit the page**

```bash
yarn type-check && yarn lint
yarn dev
```

Navigate to http://localhost:5173/notifications, confirm the page renders with the bell-drawer items as inbox + the two widget cards.

- [ ] **Step 4: Commit**

```bash
git add src/views/notifications/NotificationDashboard.vue src/locales/
git commit -m "feat(notifications): /notifications dashboard with inbox + per-source widgets"
```

---

## Task D9: `NotificationPreferences` view

**Files:**
- Create: `saas-front-end/src/views/notifications/NotificationPreferences.vue`

- [ ] **Step 1: Implement the preferences view**

```vue
<!-- saas-front-end/src/views/notifications/NotificationPreferences.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useNotificationStore } from '@/stores/notification';
import { useClientStore } from '@/stores/client';
import { coreAxiosInstance } from '@/api/core';
import type { NotificationPreference, NotificationType } from 'commons';

interface ProjectRef { id: string; name: string; client_id: string; }

const store = useNotificationStore();
const clientStore = useClientStore();

const projects = ref<ProjectRef[]>([]);
const expandedTypes = ref<Set<NotificationType>>(new Set());
const saving = ref(false);

onMounted(async () => {
  await store.fetchPreferences();
  const r = await coreAxiosInstance.get('/projects', { params: { all_clients: true } });
  projects.value = r.data;
});

function toggleExpand(type: NotificationType) {
  if (expandedTypes.value.has(type)) expandedTypes.value.delete(type);
  else expandedTypes.value.add(type);
  expandedTypes.value = new Set(expandedTypes.value);
}

function getOverride(pref: NotificationPreference, projectId: string): boolean {
  const ov = pref.overrides.find((o) => o.project_id === projectId);
  return ov ? ov.enabled : true;
}

function setOverride(pref: NotificationPreference, projectId: string, enabled: boolean) {
  const existing = pref.overrides.find((o) => o.project_id === projectId);
  if (enabled === true) {
    pref.overrides = pref.overrides.filter((o) => o.project_id !== projectId);
  } else if (existing) {
    existing.enabled = false;
  } else {
    pref.overrides.push({
      project_id: projectId,
      project_name: projects.value.find((p) => p.id === projectId)?.name ?? '',
      enabled: false,
    });
  }
}

async function save() {
  saving.value = true;
  try {
    await store.savePreferences(store.preferences);
  } finally {
    saving.value = false;
  }
}
</script>

<template>
  <div class="notif-prefs">
    <header class="notif-prefs__header">
      <h1>{{ $t('notifications.preferences') }}</h1>
    </header>

    <table class="notif-prefs__table">
      <thead>
        <tr>
          <th>{{ $t('notifications.prefs.eventType') }}</th>
          <th>{{ $t('notifications.prefs.globalEnabled') }}</th>
          <th>{{ $t('notifications.prefs.perProject') }}</th>
        </tr>
      </thead>
      <tbody>
        <template v-for="pref in store.preferences" :key="pref.type">
          <tr>
            <td>{{ $t(`notifications.types.${pref.type}`) }}</td>
            <td>
              <input type="checkbox" v-model="pref.enabled" />
            </td>
            <td>
              <button @click="toggleExpand(pref.type)">
                {{ expandedTypes.has(pref.type) ? '−' : '+' }} {{ pref.overrides.length }}
              </button>
            </td>
          </tr>
          <tr v-if="expandedTypes.has(pref.type)">
            <td colspan="3" class="notif-prefs__overrides">
              <div v-for="p in projects" :key="p.id" class="notif-prefs__project">
                <label>
                  <input
                    type="checkbox"
                    :checked="getOverride(pref, p.id)"
                    @change="(e) => setOverride(pref, p.id, (e.target as HTMLInputElement).checked)"
                  />
                  {{ p.name }}
                </label>
              </div>
            </td>
          </tr>
        </template>
      </tbody>
    </table>

    <footer class="notif-prefs__footer">
      <button @click="save" :disabled="saving">
        {{ saving ? $t('common.saving') : $t('common.save') }}
      </button>
    </footer>
  </div>
</template>

<style scoped lang="scss">
.notif-prefs { padding: 24px; max-width: 800px; margin: 0 auto;
  &__header { margin-bottom: 24px; }
  &__table { width: 100%; border-collapse: collapse;
    th, td { padding: 12px; border-bottom: 1px solid var(--color-border); text-align: left; }
  }
  &__overrides { background: var(--color-bg-highlight); padding: 16px; }
  &__project { padding: 4px 0; }
  &__footer { margin-top: 24px; text-align: right; }
}
</style>
```

- [ ] **Step 2: Add i18n keys**

```json
{
  "notifications": {
    "prefs": {
      "eventType": "Event type",
      "globalEnabled": "Enabled (global)",
      "perProject": "Per-project overrides"
    },
    "types": {
      "gtm.new_version": "GTM: new container version",
      "qa.audit_finished": "QA: audit finished",
      "qa.audit_failed": "QA: audit failed"
    }
  },
  "common": {
    "save": "Save",
    "saving": "Saving..."
  }
}
```

- [ ] **Step 3: Type-check + lint + manual check**

```bash
yarn type-check && yarn lint
yarn dev
```

Navigate to http://localhost:5173/notifications/preferences. Toggle preferences, save, refresh, confirm they persisted.

- [ ] **Step 4: Commit**

```bash
git add src/views/notifications/NotificationPreferences.vue src/locales/
git commit -m "feat(notifications): preferences page"
```

---

## Task D10: Push frontend branch + open PR

- [ ] **Step 1: Lint + type-check before push (Husky enforces but run anyway)**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-front-end
yarn lint && yarn type-check && yarn test
```

Expected: all PASS.

- [ ] **Step 2: Push branch**

```bash
git push origin feature/notifications-phase1
```

- [ ] **Step 3: Open PR**

Open PR `saas-front-end` -> `dev`. Title: `feat(notifications): Phase 1 frontend (bell + drawer + dashboard + preferences)`. Reviewers @nyfen + @joel. Link to the spec.

---

# Post-merge verification

After all PRs are merged + deployed to dev:

- [ ] **V1: Trigger a GTM Monitor poll manually**

```bash
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-tag-management-api
docker compose exec saas-tag-management-api php artisan gtm-monitor:poll
```

If a NEW_VERSION is detected, confirm notification appears in the bell badge within 30s.

- [ ] **V2: Trigger a QA audit**

Run a QA audit through the UI. On completion, confirm notification appears in the bell.

- [ ] **V3: Verify preferences mute**

Disable `gtm.new_version` in preferences, trigger another GTM poll that would produce a notification, confirm none appears for that user.

- [ ] **V4: Verify retention command**

```bash
docker compose exec saas-core-api php artisan notifications:sweep
```

Expected: "Archived X, deleted Y" output (likely both 0 immediately after launch).

- [ ] **V5: Confirm Joel + nyfen sign-off on all four PRs**

---

# Phase 2 deferred work (NOT in this plan)

See spec section 13. Out of scope for this implementation plan. Pre-requisites: Laravel 11 upgrade in all four backends + Reverb infra wired into deployment. When ready, implement as a separate plan.
