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:
- 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.
- Phase B lands in saas-core-api: REST API, queue job, retention command.
- 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.
- 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.
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.
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.
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.
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
Edit commons/composer.json and commons/package.json: bump version field (e.g., 1.2.0 → 1.3.0). Match whichever versioning scheme the repo uses.
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
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:
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.
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:
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".
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.
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.
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.
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.
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.
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.
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.
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.
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
cd /Users/yoanyahemdi/Projects/taginsight/saas_production/saas-core-api
git push origin feature/notifications-phase1
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.
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.
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.
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:
// 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);
},
};
yarn type-check
Expected: PASS.
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.
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:
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.
git add src/App.vue
git commit -m "feat(notifications): start/stop polling on login/logout"
Task D5: NotificationRow shared component
Files:
<!-- 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.
yarn type-check
Expected: clean.
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') }} →</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.
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:
{
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.
git add src/router/
git commit -m "feat(notifications): add /notifications routes"
Files:
<!-- 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.
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:
<!-- 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.
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.
git push origin feature/notifications-phase1
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.