TagInsight Notifications

Generated 2026-05-21 · spec + Phase 1 plan

TagInsight Notifications: Design Spec

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

1. Summary

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

Three notification sources in v1:

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

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


2. Goals and non-goals

Goals

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

Non-goals (v1)

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

3. Constraints and context

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

4. Phasing

Phase 1 (v1, ships first)

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

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

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

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

5. Architecture

5.1 Component map

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

5.2 Event flow (Phase 1)

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

5.3 Why this shape

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

6. Data model

6.1 notifications table

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

Indexes (MySQL, no partial index support):

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

6.2 notification_preferences table

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

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

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

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

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

6.3 Recipient resolution query

Pseudo-Eloquent:

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

6.4 Retention

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

7. Event emission

7.1 GTM Monitor: new container version

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

7.2 QA Datalayer: audit finished

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

7.3 QA Datalayer: audit failed

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

7.4 SendNotificationJob

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

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

The job:

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

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


8. REST API

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

8.1 Endpoints

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

8.2 GET /notifications query params

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

8.3 GET /notifications/unread-count response

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

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

8.4 GET /notification-preferences response

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

PUT accepts the same shape and replaces atomically.


9. Frontend

9.1 New files

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

9.2 Pinia store shape

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

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

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

9.3 Routing

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

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

9.4 Bell + drawer placement

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

9.5 Dashboard layout

Three sections within NotificationDashboard.vue:

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

9.6 Preferences page

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

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

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

9.7 i18n

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


10. Cross-cutting

10.1 Authorization

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

10.2 Multi-client scoping

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

10.3 Testing

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

10.4 Observability

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

10.5 Failure modes

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

11. Migration / deployment notes

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

12. Open questions for review

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

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

13. Phase 2 addendum (for future reference)

When the Laravel 11 upgrade + Reverb infrastructure are ready:

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

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:

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:

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
// 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
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
// 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
vendor/bin/phpunit tests/Unit/Models/NotificationTest.php

Expected: PASS, 3 tests.

  • [ ] Step 5: Commit
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
// 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
vendor/bin/phpunit tests/Unit/Models/NotificationPreferenceTest.php

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

  • [ ] Step 3: Implement the model
<?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
vendor/bin/phpunit tests/Unit/Models/NotificationPreferenceTest.php

Expected: PASS, 3 tests.

  • [ ] Step 5: Commit
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
// 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
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):

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
vendor/bin/phpunit tests/Unit/Models/UserNotificationRelationsTest.php

Expected: PASS, 2 tests.

  • [ ] Step 5: Commit
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

// 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):

export * from './types/notification';
  • [ ] Step 3: Build the package
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/commons
yarn build

Expected: build succeeds, no TypeScript errors.

  • [ ] Step 4: Commit
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.01.3.0). Match whichever versioning scheme the repo uses.

  • [ ] Step 2: Commit + tag
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

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:

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
docker compose exec saas-core-api php artisan migrate

Expected: "Migrated: ... create_notifications_table".

  • [ ] Step 4: Verify schema
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
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

docker compose exec saas-core-api php artisan make:migration create_notification_preferences_table
  • [ ] Step 2: Implement the migration
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
docker compose exec saas-core-api php artisan migrate

Expected: "Migrated: ... create_notification_preferences_table".

  • [ ] Step 4: Verify uniqueness works
docker compose exec saas-core-api php artisan tinker

Inside tinker:

$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
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
// 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
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
docker compose exec saas-core-api php artisan test --filter=NotificationPolicyTest

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

  • [ ] Step 3: Implement the policy
<?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:

protected $policies = [
    // ... existing ...
    \TagInsight\Commons\Models\Notification::class => \App\Policies\NotificationPolicy::class,
];
  • [ ] Step 5: Run test to verify it passes
docker compose exec saas-core-api php artisan test --filter=NotificationPolicyTest

Expected: PASS, 2 tests.

  • [ ] Step 6: Commit
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
// 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
docker compose exec saas-core-api php artisan test --filter=SendNotificationJobTest

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

  • [ ] Step 3: Implement the job
<?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
docker compose exec saas-core-api php artisan test --filter=SendNotificationJobTest

Expected: PASS, 3 tests.

  • [ ] Step 5: Commit
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
// 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
docker compose exec saas-core-api php artisan test --filter=NotificationIndexTest

Expected: FAIL, route or class not found.

  • [ ] Step 3: Implement the resource
<?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
// 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(...)):

use App\Http\Controllers\Api\NotificationController;

Route::prefix('notifications')->group(function () {
    Route::get('/', [NotificationController::class, 'index']);
});
  • [ ] Step 6: Run test to verify it passes
docker compose exec saas-core-api php artisan test --filter=NotificationIndexTest

Expected: PASS, 2 tests.

  • [ ] Step 7: Commit
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
// 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
docker compose exec saas-core-api php artisan test --filter=NotificationUnreadCountTest

Expected: FAIL, 404.

  • [ ] Step 3: Implement the method

Add to NotificationController:

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:

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
docker compose exec saas-core-api php artisan test --filter=NotificationUnreadCountTest

Expected: PASS.

  • [ ] Step 6: Commit
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
// 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
docker compose exec saas-core-api php artisan test --filter=NotificationReadTest

Expected: FAIL.

  • [ ] Step 3: Implement the controller methods

Add to NotificationController:

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
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
docker compose exec saas-core-api php artisan test --filter=NotificationReadTest

Expected: PASS, 3 tests.

  • [ ] Step 6: Commit
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
// 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
docker compose exec saas-core-api php artisan test --filter=NotificationArchiveTest

Expected: FAIL.

  • [ ] Step 3: Implement the methods
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
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
docker compose exec saas-core-api php artisan test --filter=NotificationArchiveTest

Expected: PASS, 3 tests.

  • [ ] Step 6: Commit
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
// 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
docker compose exec saas-core-api php artisan test --filter=NotificationPreferencesTest

Expected: FAIL.

  • [ ] Step 3: Implement the controller
<?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
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
docker compose exec saas-core-api php artisan test --filter=NotificationPreferencesTest

Expected: PASS, 2 tests.

  • [ ] Step 6: Commit
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
// 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
docker compose exec saas-core-api php artisan test --filter=NotificationSweepTest

Expected: FAIL.

  • [ ] Step 3: Implement the command
<?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():

$schedule->command('notifications:sweep')->dailyAt('03:00')->timezone('Europe/Paris');
  • [ ] Step 5: Run tests to verify they pass
docker compose exec saas-core-api php artisan test --filter=NotificationSweepTest

Expected: PASS, 2 tests.

  • [ ] Step 6: Commit
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
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

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
// 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
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
// 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:

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
docker compose exec saas-tag-management-api php artisan test --filter=GtmNewVersionEmissionTest

Expected: PASS.

  • [ ] Step 7: Commit + push
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

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
// 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
docker compose exec saas-qa-datalayer-api php artisan test --filter=QaAuditFinishedEmissionTest

Expected: FAIL.

  • [ ] Step 4: Implement the emitter
<?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:

use App\Services\Notifications\NotificationEmitter;

app(NotificationEmitter::class)->qaAuditFinished($qaJob);
  • [ ] Step 6: Run tests to verify they pass
docker compose exec saas-qa-datalayer-api php artisan test --filter=QaAuditFinishedEmissionTest

Expected: PASS.

  • [ ] Step 7: Commit
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
// 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)
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:

app(NotificationEmitter::class)->qaAuditFailed($qaJob, $qaJob->error_message ?? 'unknown error');
  • [ ] Step 4: Commit + push
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
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-front-end
yarn install
  • [ ] Step 3: Verify types import
yarn type-check

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

  • [ ] Step 4: Commit
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

// 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
yarn type-check

Expected: PASS.

  • [ ] Step 3: Commit
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

// 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
yarn test src/stores/__tests__/notification.spec.ts

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

  • [ ] Step 3: Implement the store
// 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
yarn test src/stores/__tests__/notification.spec.ts

Expected: PASS, 3 tests.

  • [ ] Step 5: Commit
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:

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
yarn type-check && yarn lint

Expected: clean.

  • [ ] Step 3: Commit
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

<!-- 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
yarn type-check

Expected: clean.

  • [ ] Step 3: Commit
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

<!-- 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
<!-- 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:

<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:

{
  "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
yarn type-check && yarn lint

Expected: clean.

  • [ ] Step 6: Commit
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

{
  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
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

<!-- 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
{
  "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
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
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

<!-- 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
{
  "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
yarn type-check && yarn lint
yarn dev

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

  • [ ] Step 4: Commit
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)
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-front-end
yarn lint && yarn type-check && yarn test

Expected: all PASS.

  • [ ] Step 2: Push branch
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
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
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.