TagInsight / notifications
spec + plan 2026-05-21 phase 1, polling
In-app · Polling v1 · Reverb-ready v2

Notifications, without waiting
on the infra.

A first-class notification system across the multi-repo stack. Three event sources today, one inbox to triage them, three surfaces to act. Built so nothing blocks on the pending Laravel 11 upgrade or the Reverb deployment story.

§ 01Feature

Three signals in, one inbox, three surfaces out.

In-app only. No email, no Slack, no Web Push in v1. Persisted to DB; polled every 30s.
Source · GTM

New container version

Fired when the GTM Monitor poller detects a NEW_VERSION on a container it watches.

app/GtmMonitor/GtmMonitorPoller.php
Source · QA

Audit finished

When a QAJob reaches a successful terminal state. Payload carries pass / total / errors_found.

saas-qa-datalayer-api
Source · QA

Audit failed

Distinct from "audit found errors". Fires when the job itself crashed, timed out, or could not run.

saas-qa-datalayer-api
Notification inbox

One persistent log, scoped per user, filterable per client.

SendNotificationJob resolves recipients (mute-aware), bulk-inserts, marks read / unread / archived. Auto-archive at 30 days, hard-delete at 90.

global nav

Bell + drawer

Badge with unread count (cap "9+"). Drawer with Unread / All tabs, 20 most recent, deep-link on click.

/notifications

Command center

Paginated inbox + filter bar + per-source widgets that deep-link into the GTM Monitor and QA Datalayer modules.

/notifications/preferences

Mute controls

Per event type plus per-project overrides. Absence of row = enabled. Mute rows stored compactly.

§ 02Architecture

Origin APIs write, core-api serves, the frontend polls.

Reuses the existing shared-DB pattern. Phase 2 layers Reverb on top, no model or REST change.
ORIGIN APIS CORE-API · QUEUE + REST SAAS-FRONT-END SAAS-TAG-MANAGEMENT-API GtmMonitorPoller → NotificationEmitter::gtmNewVersion() SAAS-QA-DATALAYER-API QAJob state machine → qaAuditFinished() · qaAuditFailed() dispatch SendNotificationJob dispatch SendNotificationJob QUEUE WORKER SendNotificationJob 1. Resolve recipients (mute-aware) 2. Bulk INSERT into notifications 3. PHASE 2 → broadcast(NotificationCreated) insert notifications · notification_preferences SHARED MYSQL REST · CORE-API 9 endpoints · auth:sanctum NotificationPolicy enforces user_id scope /notifications · /notification-preferences /unread-count PINIA · notification.ts startPolling() 30s setInterval + visibilitychange refetchUnreadCount() markRead · archive · fetchInbox SURFACES Bell + drawer global nav Command center /notifications Mute preferences /notifications/preferences
Tulip pathThe hot path triggered by an event. Cross-API dispatch through the shared queue. Pre-flight PF-01 confirms the mechanism.
Azure labelsIdentify which repo a box lives in. Emitters add a hook into an existing call site (poller, state machine).
Dotted diskShared MySQL. Same instance the rest of TagInsight already accesses via commons.
Dashed lineFrontend polling loop. Replaced by a Reverb subscription in Phase 2 without any other change.
§ 03Data model

Two tables. One denormalized for fast lists, one compact for mutes.

Indexes shaped for MySQL (no partial indexes). UUID PKs throughout, matching the existing schema.

notifications

12 cols · 3 indexes
iduuidPrimary key.
user_iduuidRecipient. The row is private to this user.
client_iduuidDenormalized for fast cross-client filtering.
project_iduuid · nullNullable for future client-scoped events.
typevarchar(64)Enum-as-string. gtm.new_version · qa.audit_finished · qa.audit_failed.
titlevarchar(255)Denormalized; rendered in lists without further query.
bodytextOne or two sentences max.
payloadjsonEvent-specific (qa_job_id, published_version_id, counts).
action_urlvarchar(1024)Deep link to the GTM Monitor or QA run-detail page.
read_attimestamp · nullNULL means unread. Drives the bell badge.
archived_attimestamp · nullNULL means in the live inbox. Set at 30d by sweep.
created_at, updated_attimestampsStandard Eloquent timestamps.

notification_preferences

5 cols · 1 unique
iduuidPrimary key.
user_iduuidOwner of the preference row.
typevarchar(64)Same enum as notifications.type.
project_iduuid · nullNULL = applies to every project the user can see.
enabledbooleanDefault true. Only stored when false (mute row). Absence of row = enabled.
created_at, updated_attimestampsStandard timestamps.
§ 04REST surface

Nine endpoints, all on core-api.

Auth via sanctum. NotificationPolicy enforces user_id ownership on every mutating route.
MethodPathDescription
GET /api/notifications Paginated inbox. Filters: client_id (or "all"), archived, read, type[], project_id[]. Cursor pagination.
GET /api/notifications/unread-count Returns total + per-client breakdown. The 30s polling endpoint.
POST /api/notifications/{id}/read Mark a single notification read. 403 if not owner.
POST /api/notifications/{id}/unread Toggle back to unread.
POST /api/notifications/mark-all-read Bulk mark read. Honors client_id and type filters from the request body.
POST /api/notifications/{id}/archive Archive a single notification.
POST /api/notifications/bulk-archive Archive by array of ids; silently skips ids not owned by the caller.
GET /api/notification-preferences All 3 types with global enabled + per-project overrides. Used by the preferences page.
PUT /api/notification-preferences Atomic replace inside a transaction. Only mute rows are persisted; defaults stay implicit.
§ 05UI mockups

What it looks like.

Schematic-grade for shape and density. Final styling matches the existing TagInsight design language.
Global nav · drawer open
app.taginsight.io/projects/acme/overview
3
Notifications
Mark all read
Unread (3) All
!
QA audit failed to complete
Reason: timeout after 30 minutes
2m ago · acme
G
New GTM container version: GTM-XYZ v42
Review the changes in the GTM Monitor.
18m ago · acme
QA audit finished with 2 errors
18/20 checks passed. See results.
1h ago · acme
View all notifications →
/notifications · command center
app.taginsight.io/notifications
Notifications
Preferences →
Unread All types All projects All clients
QA audit failed to complete acme · qa.audit_failed
2m
New GTM container version acme · gtm.new_version
18m
QA audit finished with 2 errors acme · qa.audit_finished
1h
New GTM container version beta · gtm.new_version
1d
QA audit finished successfully beta · qa.audit_finished
2d
Latest GTM versions
Recent QA audits
§ 06Work breakdown

Four phases, roughly thirty tasks.

Each phase ships independently to its own repo, on a feature/notifications-phase1 branch. PRs to dev with @nyfen and @joel.
A commons/

Shared models & types

5 tasks
  • Notification model + tests
  • NotificationPreference model + tests
  • User relations
  • TypeScript types in src/types
  • Version bump + tag + PR
Depends on nothing · blocks B, C, D
B saas-core-api/

REST · queue · retention

11 tasks
  • Two migrations (notifications + preferences)
  • NotificationPolicy + factory
  • SendNotificationJob (mute-aware)
  • Six endpoints: index, unread-count, read, mark-all-read, archive, bulk-archive
  • Preferences endpoints (index + atomic update)
  • notifications:sweep daily command
Depends on A · blocks C, D
C qa-datalayer · tms

Event emitters

3 tasks
  • GTM Monitor: gtm.new_version + poller hook
  • QA: qa.audit_finished + success transition hook
  • QA: qa.audit_failed + failure transition hook
Depends on A, B · parallel with D
D saas-front-end/

Store · components · views

10 tasks
  • commons bump + service wrapper
  • Pinia store with polling (30s + on focus)
  • App.vue login/logout lifecycle
  • NotificationRow · Bell · Drawer
  • Routes + i18n keys
  • /notifications dashboard
  • /notifications/preferences page
Depends on A, B deployed · parallel with C
§ 07Pre-flight

Four open questions to lock before coding.

None block writing the plan. All should have an answer before the first PR opens.
PF · 01

Cross-API job dispatch

Origin APIs dispatch a Laravel job whose class lives in core-api. Confirm this works through the shared queue, or fall back to a named-queue message that a core-api worker consumes.

grep -rn "dispatch(new " saas-qa-datalayer-api/app | head
grep -rn "Bus::dispatch" saas-core-api | head
PF · 02

Active-client source on backend

GET /notifications defaults client_id to the user's currently active client. Standardize where core-api reads that from (request header, session, query param) to match the rest of the app.

PF · 03

commons versioning workflow

The plan assumes a version bump + composer/yarn update across all backends and the frontend. Confirm the exact mechanism (path repo, packagist, git tag) used today.

cat commons/composer.json
grep -A2 commons saas-core-api/composer.json
PF · 04

Deep-link URL shapes

The action_url field carries the URL the "View" button opens. Drafts: /projects/{id}/gtm-monitor?version=… and /projects/{id}/qa-datalayer/jobs/{id}. Confirm before hard-coding into the emitters.

§ 08Deferred

What Phase 2 adds on top.

Pre-reqs: Laravel 11 across all backends + Reverb wired into deploy infra.

Same shape, real-time pipe.

v1 ships with polling because Joel hasn't figured out Reverb infra yet and the backends are still on Laravel 10 (Reverb wants 11). When both unblock, broadcasting drops in as an additive layer. Frontend switches polling off while the WebSocket is connected and falls back if it drops.

Blocked by L11 upgrade + Reverb infra
01
Laravel 11 upgrade

All four backends bumped together.

"laravel/framework": "^11.0"
02
Reverb in core-api + env in others

laravel/reverb installed centrally; BROADCAST_DRIVER + REVERB_* set in every emitter backend.

03
NotificationCreated event in commons

ShouldBroadcast on PrivateChannel("user.{id}.notifications"). Used by SendNotificationJob.

04
Uncomment the broadcast line

Single line in SendNotificationJob, already marked with a TODO. No other backend change.

05
Echo subscription in the store

Wire-up in startPolling(). Polling pauses while connected; resumes on disconnect.