# Solution Design Document

## Validation Checklist

- [ ] All required sections are complete
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] All context sources are listed with relevance ratings
- [ ] Project commands are discovered from actual project files
- [ ] Constraints -> Strategy -> Design -> Implementation path is logical
- [ ] Architecture pattern is clearly stated with rationale
- [ ] Every component in diagram has directory mapping
- [ ] Every interface has specification
- [ ] Error handling covers all error types
- [ ] Quality requirements are specific and measurable
- [ ] Every quality requirement has test coverage
- [ ] **All architecture decisions confirmed by user**
- [ ] Component names consistent across diagrams
- [ ] A developer could implement from this design

---

## Constraints

### Framework Constraints
- **CON-1**: PHP 8.x with Slim 2.6.2 framework (no upgrade path)
- **CON-2**: Twig 1.44.8 templating with Handlebars for client-side (wrapped in `{% raw %}`)
- **CON-3**: Bootstrap 5.3.3 CSS framework with custom design tokens
- **CON-4**: Syncfusion EJ2 components MUST be used over custom implementations (per CLAUDE.md)
- **CON-5**: Font Awesome 6 icons (v4-shims available)

### Infrastructure Constraints
- **CON-6**: Multi-store DB architecture: central `kiosk_buykiosk` + `kiosk_users` + per-store `kiosk_{typeNum}`
- **CON-7**: Redis available for caching and rate limiting
- **CON-8**: Ably realtime messaging with `AblyPublishThrottle` (40 msg/sec Redis-based rate limit)
- **CON-9**: No scheduling infrastructure exists - alerts publish immediately only
- **CON-10**: Database migrations via JSON migration system (`userfrosting/conductor run`)

### Integration Constraints
- **CON-11**: Ably token auth via `ABLY_KEY` env var, clientId format `user:{userId}`, 1-hour TTL
- **CON-12**: Existing chat notification system (`ChatNotifications`) must continue working during and after migration
- **CON-13**: `WorkbookToast` (delivery failure toasts) is a separate module that remains independent

### Security Constraints
- **CON-14**: Permission system via `uf_authorize_group` table with hook-based access control
- **CON-15**: Alert creation restricted to `uri_bkadmin` permission holders only
- **CON-16**: CSRF token validation on all POST/PUT/DELETE endpoints
- **CON-17**: XSS prevention required for all user-generated alert content

### Performance Constraints
- **CON-18**: Page load must not increase by more than 100ms due to alert fetching (per PRD: <100ms for typical usage with <10 active alerts)
- **CON-19**: Alert delivery via Ably must reach clients within 2 seconds of publish
- **CON-20**: Max 3 visible notifications globally (combined banners + toasts) per PRD

## Implementation Context

### Required Context Sources

- ICO-1 Ably Integration Layer
  ```yaml
  - file: userfrosting/src/BuyerKiosk/Core/AblyPublishThrottle.php
    relevance: HIGH
    why: "Redis-based rate limiting pattern (40 msg/sec) that all Ably publishers must use"

  - file: userfrosting/src/BuyerKiosk/Chat/Events/ChatAblyPublisher.php
    relevance: HIGH
    why: "Canonical Ably publisher pattern to follow for alert publishing"

  - file: userfrosting/src/BuyerKiosk/StaffChat/Controllers/StaffChatApiController.php
    relevance: HIGH
    sections: [lines 1794-1825 - Ably token generation]
    why: "Token auth pattern with clientId format and TTL configuration"

  - file: public_html/js/workspace/modules/workbook/ably-sync.js
    relevance: HIGH
    why: "WorkbookAblySync class - client-side Ably connection, message deduplication, channel subscription patterns"
  ```

- ICO-2 Chat Notification System (Migration Target)
  ```yaml
  - file: public_html/js/workspace/modules/chat/chat-notifications.js
    relevance: CRITICAL
    why: "ChatNotifications class - the primary migration target. Handles toast rendering, sound, browser notifications, consolidation. Must understand to absorb into unified framework"

  - file: public_html/js/workspace/modules/chat/chat-manager.js
    relevance: HIGH
    why: "ChatManager orchestrator with embedded/overlay modes - coordinates all chat components including notifications"

  - file: public_html/js/workspace/modules/shared/WorkbookToast.js
    relevance: MEDIUM
    why: "Simple toast for delivery failures - separate module, stays independent. Reference for toast container positioning (top-0 end-0, z-index 1090)"
  ```

- ICO-3 Admin Layout & Navbar
  ```yaml
  - file: userfrosting/templates/themes/default/workspace/layouts/workspace-head.html
    relevance: CRITICAL
    why: "Header structure with .header-actions div - location for notification bell. Contains demo banner pattern. z-index 1030 on header"

  - file: userfrosting/templates/themes/default/menus/sidebar.html
    relevance: HIGH
    why: "Admin sidebar menu structure - need to add System Alerts menu item with permission gating"

  - file: userfrosting/templates/themes/default/admin/admin-layout.html
    relevance: MEDIUM
    why: "Admin page layout wrapper - banner insertion point above page content"
  ```

- ICO-4 Database & Migrations
  ```yaml
  - file: userfrosting/migrations/input/
    relevance: HIGH
    why: "JSON migration file format and patterns for creating tables, adding permissions"

  - file: userfrosting/src/BuyerKiosk/Core/Migration/
    relevance: HIGH
    why: "Migration runner, checkQuery logic, migration_log in central DB"

  - doc: docs/specs/040-system-alerts/product-requirements.md
    relevance: CRITICAL
    why: "Complete PRD with all requirements, edge cases, and acceptance criteria"
  ```

- ICO-5 API & Controller Patterns
  ```yaml
  - file: userfrosting/routes/support/api.php
    relevance: HIGH
    why: "Modern route pattern with checkAccessAndReturnStoreObject() helper"

  - file: userfrosting/src/BuyerKiosk/Support/Controllers/SupportApiController.php
    relevance: HIGH
    why: "Modern controller pattern with service injection and JSON responses"

  - file: public_html/js/admin/team-members/TeamMemberGrid.js
    relevance: MEDIUM
    why: "Syncfusion EJ2 Grid pattern for admin data display"
  ```

- ICO-6 Permission System
  ```yaml
  - file: userfrosting/migrations/input/20251203_002_support_portal_permissions.json
    relevance: HIGH
    why: "Permission migration pattern - adding hooks to uf_authorize_group"
  ```

### Implementation Boundaries

- **Must Preserve**:
  - Existing chat notification behavior (sound, click-to-thread, consolidation, badge counts)
  - WorkbookToast delivery failure toasts (independent module)
  - Ably channel structure for existing store channels
  - All existing permission checks and store scoping patterns
  - `ChatManager` orchestrator interface (`init()`, `destroy()`, mode switching)

- **Can Modify**:
  - `ChatNotifications` toast rendering to delegate to unified `NotificationManager`
  - `workspace-head.html` to add notification bell and banner container
  - Sidebar menu to add System Alerts admin link
  - Ably token generation to include alert channel capabilities

- **Must Not Touch**:
  - `WorkbookToast.js` (stays independent for delivery failure toasts)
  - Store-level databases (alert tables go in central DB only)
  - Existing Ably store channel subscriptions
  - Mobile API endpoints
  - Legacy buyQueue JS

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    Admin[BK Admin User] -->|Create/Edit/Delete Alerts| AlertAdmin[System Alerts Admin Page]
    AlertAdmin -->|CRUD API| AlertAPI[Alert API Controller]
    AlertAPI -->|Read/Write| CentralDB[(kiosk_buykiosk DB)]
    AlertAPI -->|Publish| Ably[Ably Realtime]

    Ably -->|Push Events| StaffBrowser[Staff Browser - Workbook]
    Ably -->|Push Events| AdminBrowser[Admin Browser]

    StaffBrowser -->|Fetch on Load| StaffAPI[Staff Alert API]
    StaffAPI -->|Read| CentralDB
    StaffBrowser -->|Acknowledge| StaffAPI

    AdminBrowser -->|View Dashboard| AlertAdmin
    AlertAdmin -->|Read Analytics| AlertAPI

    ChatSystem[Existing Chat System] -->|Delegate Toast Rendering| NotifMgr[NotificationManager]
    AlertAPI -->|Delegate Display| NotifMgr
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Admin Web Interface"
    type: HTTPS
    format: REST JSON
    authentication: Session + CSRF + uri_bkadmin permission
    data_flow: "Alert CRUD operations, acknowledgment dashboard queries"

  - name: "Staff Web Interface (Workbook)"
    type: HTTPS
    format: REST JSON
    authentication: Session + CSRF + store assignment check
    data_flow: "Fetch pending alerts, submit acknowledgments"

  - name: "Ably Realtime (Client)"
    type: WebSocket
    format: JSON messages
    authentication: Ably token auth (clientId: user:{userId})
    data_flow: "Receive new/updated/deleted alert events in realtime"

# Outbound Interfaces
outbound:
  - name: "Ably Realtime (Server)"
    type: HTTPS (REST publish)
    format: JSON
    authentication: ABLY_KEY server-side
    data_flow: "Publish alert events to channels"
    criticality: HIGH
    rate_limit: "40 msg/sec via AblyPublishThrottle"

# Data Interfaces
data:
  - name: "Central Database (kiosk_buykiosk)"
    type: MySQL/MariaDB
    connection: PDO via dbConnectCentral()
    data_flow: "System alerts, store targets, acknowledgments"

  - name: "Users Database (kiosk_users)"
    type: MySQL/MariaDB
    connection: PDO via dbConnectUsers()
    data_flow: "User info, store assignments for targeting"

  - name: "Redis"
    type: Redis
    connection: Predis client
    data_flow: "Ably publish throttling"
```

### Project Commands

```bash
# Environment Setup
Install Dependencies: cd userfrosting && composer install
Start Development: Local dev via ngrok to dev2.buyerkiosk.com (no deploy needed)

# Testing Commands
Unit Tests: ./test.sh --testsuite unit
Integration Tests: ./test.sh --testsuite integration
Targeted Tests: cd userfrosting && ./vendor/bin/phpunit --filter "SystemAlert"
All Tests with Coverage: ./test.sh --coverage
Static Analysis: cd userfrosting && ./vendor/bin/phpstan analyse

# CSS Build
Development: php userfrosting/conductor build-css
Production: php userfrosting/conductor build-css --minify
Watch: php userfrosting/conductor build-css --watch

# Database Migrations
Run Migrations: php userfrosting/conductor run
Migration Files: userfrosting/migrations/input/*.json
```

## Solution Strategy

- **Architecture Pattern**: Layered architecture following existing codebase conventions (Route -> Controller -> Service -> Repository), with a new client-side `NotificationManager` module that unifies all toast/banner rendering.

- **Integration Approach**: The system alerts feature integrates at three levels:
  1. **Backend**: New `SystemAlerts` feature namespace under `src/BuyerKiosk/Feature/SystemAlerts/` with dedicated controller, service, and repository classes. All alert data stored in central DB (`kiosk_buykiosk`).
  2. **Realtime**: Dedicated Ably channels (`alerts:global` and `alerts:{typeNum}`) for push delivery, using existing `AblyPublishThrottle` for rate limiting.
  3. **Frontend**: New `NotificationManager` module becomes the single rendering engine for system alert banners/toasts AND (via migration) chat notification toasts.

- **Justification**: This approach follows every existing pattern in the codebase (PSR-4 autoloading, Slim 2 routes, PDO repositories, Ably publish/subscribe, Syncfusion EJ2 components) while introducing the minimal new abstractions needed (NotificationManager, AlertBell). The unified notification framework satisfies PRD Feature 8 (chat migration) without breaking existing chat behavior.

- **Key Decisions**: Central DB storage (not per-store), dedicated Ably channel prefix, Syncfusion RTE for rich text, unified NotificationManager replaces direct toast rendering.

## Building Block View

### Components

```mermaid
graph LR
    subgraph Admin
        AdminPage[System Alerts Page]
        AdminGrid[Syncfusion Grid]
        AlertForm[Create/Edit Modal]
        AckDashboard[Acknowledgment Dashboard]
        RTE[Syncfusion RTE]
    end

    subgraph API
        AdminAPI[SystemAlertsAdminController]
        StaffAPI[SystemAlertsStaffController]
    end

    subgraph Service
        AlertService[SystemAlertService]
        AckService[AcknowledgmentService]
        AblyPub[AlertAblyPublisher]
    end

    subgraph Repository
        AlertRepo[SystemAlertRepository]
        AckRepo[AcknowledgmentRepository]
    end

    subgraph Frontend - Workbook
        NotifMgr[NotificationManager]
        BannerRenderer[BannerRenderer]
        ToastRenderer[ToastRenderer]
        AlertBell[AlertBellDropdown]
        AblySync[AlertAblySync]
    end

    subgraph Existing
        ChatNotif[ChatNotifications]
        ChatMgr[ChatManager]
        WbAbly[WorkbookAblySync]
    end

    AdminPage --> AdminAPI
    AdminAPI --> AlertService
    AdminAPI --> AckService
    AlertService --> AlertRepo
    AlertService --> AblyPub
    AckService --> AckRepo

    StaffAPI --> AlertService
    StaffAPI --> AckService

    AblySync --> NotifMgr
    NotifMgr --> BannerRenderer
    NotifMgr --> ToastRenderer
    AlertBell --> StaffAPI

    ChatNotif -.->|delegates toast rendering| NotifMgr
    AblyPub --> Ably[Ably Channels]
    Ably --> AblySync
    AlertRepo --> DB[(kiosk_buykiosk)]
    AckRepo --> DB
```

### Directory Map

**Backend: PHP**
```
userfrosting/
├── src/BuyerKiosk/Feature/SystemAlerts/
│   ├── Controllers/
│   │   ├── SystemAlertsAdminController.php    # NEW: Admin CRUD + dashboard API
│   │   └── SystemAlertsStaffController.php    # NEW: Staff fetch + acknowledge API
│   ├── Services/
│   │   ├── SystemAlertService.php             # NEW: Alert business logic (CRUD, targeting, expiry)
│   │   └── AcknowledgmentService.php          # NEW: Ack tracking, analytics queries
│   ├── Repositories/
│   │   ├── SystemAlertRepository.php          # NEW: Alert table queries
│   │   └── AcknowledgmentRepository.php       # NEW: Ack table queries
│   ├── Events/
│   │   └── AlertAblyPublisher.php             # NEW: Ably publish with throttle
│   └── Models/
│       ├── SystemAlert.php                    # NEW: Alert entity
│       └── AlertAcknowledgment.php            # NEW: Ack entity
├── routes/
│   └── system-alerts/
│       ├── admin.php                          # NEW: Admin page + API routes
│       └── api.php                            # NEW: Staff API routes
├── templates/themes/default/
│   ├── admin/system-alerts/
│   │   ├── index.html                         # NEW: Admin list page
│   │   ├── partials/
│   │   │   ├── alert-form-modal.html          # NEW: Create/edit modal with RTE
│   │   │   └── ack-dashboard-modal.html       # NEW: Acknowledgment analytics
│   │   └── system-alerts.js                   # NEW: Admin page JS (inline or separate)
│   ├── workspace/partials/
│   │   ├── notification-banner.html           # NEW: Banner template (Handlebars in raw)
│   │   └── alert-bell.html                    # NEW: Bell dropdown template
│   └── workspace/layouts/workspace-head.html  # MODIFY: Add bell icon + banner container
├── migrations/input/
│   ├── 040_001_system_alerts_tables.json      # NEW: Create 3 tables
│   └── 040_002_system_alerts_permissions.json  # NEW: Add permission hooks
```

**Frontend: JavaScript**
```
public_html/
├── js/workspace/modules/
│   ├── notifications/
│   │   ├── NotificationManager.js             # NEW: Unified rendering engine (queue, max 3 visible)
│   │   ├── BannerRenderer.js                  # NEW: Banner display/dismiss above page header
│   │   ├── ToastRenderer.js                   # NEW: Toast display/dismiss (replaces direct toastr)
│   │   ├── AlertBellDropdown.js               # NEW: Bell icon with history dropdown
│   │   └── AlertAblySync.js                   # NEW: Ably subscription for alert channels
│   └── chat/
│       └── chat-notifications.js              # MODIFY: Delegate toast rendering to NotificationManager
├── css/admin/modules/
│   └── system-alerts.css                      # NEW: Alert admin page styles
├── css/workspace/
│   └── notifications.css                      # NEW: Banner, toast, bell styles
```

### Interface Specifications

#### Data Storage Changes

**Table: `systemAlerts`** (NEW - in `kiosk_buykiosk`)
```yaml
id: INT AUTO_INCREMENT PRIMARY KEY
title: VARCHAR(255) NOT NULL
body: TEXT NOT NULL  # HTML from Syncfusion RTE (sanitized server-side)
severity: ENUM('info','warning','critical') NOT NULL DEFAULT 'info'
displayType: ENUM('banner','toast') NOT NULL DEFAULT 'banner'
targetType: ENUM('all','specific') NOT NULL DEFAULT 'all'
ctaLabel: VARCHAR(100) NULL  # Optional button text
ctaUrl: VARCHAR(500) NULL    # Optional button link
expiresAt: DATETIME NULL     # NULL = no auto-expiry
isActive: TINYINT(1) NOT NULL DEFAULT 1
createdBy: INT NOT NULL      # users.id of admin who created
updatedBy: INT NULL          # users.id of admin who last edited
createdAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
updatedAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
INDEX: idx_active_expires (isActive, expiresAt)
INDEX: idx_created (createdAt)
```

**Table: `systemAlertStoreTargets`** (NEW - in `kiosk_buykiosk`)
```yaml
id: INT AUTO_INCREMENT PRIMARY KEY
alertId: INT NOT NULL  # FK -> systemAlerts.id
typeNum: VARCHAR(10) NOT NULL  # Store identifier (e.g., 'ou00')
INDEX: idx_alert (alertId)
INDEX: idx_typenum (typeNum)
UNIQUE: uq_alert_store (alertId, typeNum)
FOREIGN KEY: fk_alert REFERENCES systemAlerts(id) ON DELETE CASCADE
```

**Table: `systemAlertAcknowledgments`** (NEW - in `kiosk_buykiosk`)
```yaml
id: INT AUTO_INCREMENT PRIMARY KEY
alertId: INT NOT NULL  # FK -> systemAlerts.id
userId: INT NOT NULL   # users.id
dismissedAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
INDEX: idx_alert_user (alertId, userId)
UNIQUE: uq_alert_user (alertId, userId)  # One ack per user per alert
FOREIGN KEY: fk_ack_alert REFERENCES systemAlerts(id) ON DELETE CASCADE
```

#### Internal API Changes

**Admin Endpoints** (require `uri_bkadmin` permission):

```yaml
Endpoint: List Alerts
  Method: GET
  Path: /api/system-alerts/admin/alerts
  Query Params:
    page: int (default 1)
    pageSize: int (default 20)
    status: string ('active'|'expired'|'deactivated'|'all', default 'active')
    severity: string ('info'|'warning'|'critical'|'all', default 'all')
    targetStore: string (typeNum filter, optional)
    sortBy: string ('createdAt'|'severity'|'title'|'ackProgress', default 'createdAt')
    sortDir: string ('asc'|'desc', default 'desc')
    dateFrom: string (ISO 8601, optional)
    dateTo: string (ISO 8601, optional)
  Response:
    success:
      alerts: array of SystemAlert objects (each includes ackProgress: { acknowledged: int, total: int, percent: float })
      total: int
      page: int
      pageSize: int
      summary:
        totalActive: int
        totalUnacknowledged: int (sum of pending acks across all active alerts)

Endpoint: Create Alert
  Method: POST
  Path: /api/system-alerts/admin/alerts
  Request:
    title: string (required, max 255)
    body: string (required, HTML from RTE - sanitized server-side)
    severity: string (required, 'info'|'warning'|'critical')
    displayType: string (required, 'banner'|'toast')
    targetType: string (required, 'all'|'specific')
    targetStores: array of typeNum strings (required if targetType='specific')
    ctaLabel: string (optional, max 100)
    ctaUrl: string (optional, valid URL, max 500)
    expiresAt: string (optional, ISO 8601 datetime)
  Response:
    success: { alert: SystemAlert, message: "Alert created and published" }
    error: { error: true, message: string }

Endpoint: Update Alert
  Method: PUT
  Path: /api/system-alerts/admin/alerts/:alertId
  Request: (same as Create, all fields optional)
  Response:
    success: { alert: SystemAlert, message: "Alert updated" }
  Side Effect: Deletes ALL acknowledgment records for this alert (edit = new version)

Endpoint: Toggle Alert Active (Deactivate/Reactivate)
  Method: PUT
  Path: /api/system-alerts/admin/alerts/:alertId/toggle
  Response:
    success: { alert: SystemAlert, message: "Alert activated/deactivated" }

Endpoint: Get Acknowledgment Dashboard
  Method: GET
  Path: /api/system-alerts/admin/alerts/:alertId/acknowledgments
  Response:
    success:
      alert: SystemAlert
      stats:
        totalTargeted: int
        totalAcknowledged: int
        percentAcknowledged: float
      acknowledged: array of { userId, firstName, lastName, typeNum, dismissedAt }
      pending: array of { userId, firstName, lastName, typeNum }
```

**Staff Endpoints** (require active session + store assignment):

```yaml
Endpoint: Get Pending Alerts
  Method: GET
  Path: /api/system-alerts/pending
  Response:
    success:
      alerts: array of PendingAlert objects (alerts user hasn't dismissed)

Endpoint: Acknowledge Alert
  Method: POST
  Path: /api/system-alerts/:alertId/acknowledge
  Response:
    success: { message: "Alert acknowledged" }

Endpoint: Get Alert History
  Method: GET
  Path: /api/system-alerts/history
  Query Params:
    limit: int (default 20)
  Response:
    success:
      alerts: array of { alert: SystemAlert, acknowledged: bool, dismissedAt: datetime|null }
```

#### Application Data Models

```pseudocode
ENTITY: SystemAlert (NEW)
  FIELDS:
    id: int
    title: string
    body: string (sanitized HTML)
    severity: enum('info','warning','critical')
    displayType: enum('banner','toast')
    targetType: enum('all','specific')
    targetStores: array of typeNum strings (from join)
    ctaLabel: string|null
    ctaUrl: string|null
    expiresAt: datetime|null
    isActive: bool
    createdBy: int (userId)
    updatedBy: int|null (userId)
    createdAt: datetime
    updatedAt: datetime

  BEHAVIORS:
    isExpired(): bool - checks expiresAt against current time
    isTargetedTo(typeNum): bool - checks targetType and store targets
    toArray(): array - serializes for API response
    toAblyPayload(): array - minimal payload for realtime push

ENTITY: AlertAcknowledgment (NEW)
  FIELDS:
    id: int
    alertId: int
    userId: int
    dismissedAt: datetime

ENTITY: PendingAlert (NEW - DTO for staff API)
  FIELDS:
    id: int
    title: string
    body: string
    severity: string
    displayType: string
    ctaLabel: string|null
    ctaUrl: string|null
    createdAt: datetime
```

#### Integration Points

```yaml
# Ably Realtime (Outbound - Server publishes)
AlertAblyPublisher:
  channels:
    - "alerts:global"       # For targetType='all' alerts
    - "alerts:{typeNum}"    # For targetType='specific' alerts (one publish per target store)
  events:
    - "alert:created"       # New alert published
    - "alert:updated"       # Alert edited (triggers re-fetch, ack reset)
    - "alert:deactivated"   # Alert toggled off (remove from client UI)
  payload:
    alert:created/updated: { alertId, title, body, severity, displayType, ctaLabel, ctaUrl, createdAt }
    alert:deactivated: { alertId }
  note: "No delete event - alerts are deactivated, not deleted, to preserve audit history"
  rate_limit: Uses AblyPublishThrottle (40 msg/sec shared)

# Ably Realtime (Inbound - Client subscribes)
AlertAblySync:
  subscribes_to:
    - "alerts:global"                    # All users subscribe
    - "alerts:{typeNum}" per assignment  # Subscribe to each assigned store's channel
  on_event:
    alert:created: "Add to NotificationManager queue"
    alert:updated: "Remove old, add updated to queue"
    alert:deactivated: "Remove from queue and visible display"

# Chat Migration (Internal)
ChatNotifications_Migration:
  before: "ChatNotifications renders toasts directly via toastr or custom DOM"
  after: "ChatNotifications calls NotificationManager.showToast() for rendering"
  preserved_behaviors:
    - Click-to-open-thread on toast click
    - Sound chime on new message
    - Browser notification (Notification API)
    - Consolidation logic (grouping multiple messages)
    - Badge count updates
  changed_behaviors:
    - Toast DOM rendering delegated to NotificationManager.ToastRenderer
    - Toast queue managed by NotificationManager (respects max 3 visible combined)
```

### Implementation Examples

#### Example 1: NotificationQueue (Client-Side)

**Why this example**: The queue management with max 3 visible items and priority ordering is the core complex logic on the client side. This clarifies the expected behavior.

```javascript
// NotificationManager.js - Queue management
var NotificationManager = (function() {
    'use strict';

    var MAX_VISIBLE = 3;
    var _visibleItems = [];   // Currently displayed (banners + toasts combined)
    var _queue = [];          // Waiting to be displayed
    var _acknowledgedIds = new Set(); // Track dismissed alerts (multi-tab dedup)

    // Priority: critical=3, warning=2, info=1, chat=0
    var PRIORITY = { critical: 3, warning: 2, info: 1, chat: 0 };

    function enqueue(notification) {
        // Dedup: don't show already-acknowledged alerts
        if (notification.alertId && _acknowledgedIds.has(notification.alertId)) return;

        // Dedup: don't re-add if already visible or queued
        if (_findById(notification.id)) return;

        // Insert into queue sorted by priority (highest first), then createdAt (newest first)
        // Edited alerts use updatedAt for ordering (treated as "re-published")
        var inserted = false;
        for (var i = 0; i < _queue.length; i++) {
            var notifPriority = PRIORITY[notification.severity] || 0;
            var queuePriority = PRIORITY[_queue[i].severity] || 0;
            if (notifPriority > queuePriority) {
                _queue.splice(i, 0, notification);
                inserted = true;
                break;
            }
            // Same severity: newer (higher timestamp) comes first
            if (notifPriority === queuePriority && notification.createdAt > _queue[i].createdAt) {
                _queue.splice(i, 0, notification);
                inserted = true;
                break;
            }
        }
        if (!inserted) _queue.push(notification);

        _processQueue();
    }

    function _processQueue() {
        while (_visibleItems.length < MAX_VISIBLE && _queue.length > 0) {
            var next = _queue.shift();
            _visibleItems.push(next);
            _render(next);
        }
    }

    function dismiss(notificationId, alertId) {
        // Remove from visible
        _visibleItems = _visibleItems.filter(function(n) { return n.id !== notificationId; });
        _removeFromDOM(notificationId);

        // Track acknowledgment for system alerts (not chat)
        if (alertId) {
            _acknowledgedIds.add(alertId);
            // POST to /api/system-alerts/{alertId}/acknowledge
            _sendAcknowledgment(alertId);
            // Sync to other tabs via localStorage
            _broadcastDismissal(alertId);
        }

        // Show next queued item
        _processQueue();
    }

    // ... render delegates to BannerRenderer or ToastRenderer based on displayType
})();
```

#### Example 2: AlertAblySync (Client-Side Channel Subscription)

**Why this example**: The multi-channel subscription pattern based on user store assignments is non-trivial and needs to handle the case where the same alert arrives on multiple channels.

```javascript
// AlertAblySync.js - Ably subscription for alert channels
var AlertAblySync = (function() {
    'use strict';

    var _subscriptions = [];
    var _processedMessageIds = new Set(); // Dedup across channels

    function init(ablyClient, userStoreAssignments) {
        // Always subscribe to global alerts channel
        var globalChannel = ablyClient.channels.get('alerts:global');
        _subscribeToChannel(globalChannel);

        // Subscribe to each assigned store's alerts channel
        userStoreAssignments.forEach(function(typeNum) {
            var storeChannel = ablyClient.channels.get('alerts:' + typeNum);
            _subscribeToChannel(storeChannel);
        });
    }

    function _subscribeToChannel(channel) {
        channel.subscribe('alert:created', function(message) {
            if (_isDuplicate(message)) return;
            NotificationManager.enqueue({
                id: 'alert-' + message.data.alertId,
                alertId: message.data.alertId,
                title: message.data.title,
                body: message.data.body,
                severity: message.data.severity,
                displayType: message.data.displayType,
                ctaLabel: message.data.ctaLabel,
                ctaUrl: message.data.ctaUrl,
                type: 'system-alert'
            });
        });

        channel.subscribe('alert:updated', function(message) {
            if (_isDuplicate(message)) return;
            NotificationManager.remove('alert-' + message.data.alertId);
            NotificationManager.enqueue({ /* same as above with updated data */ });
        });

        channel.subscribe('alert:deactivated', function(message) {
            NotificationManager.remove('alert-' + message.data.alertId);
        });

        _subscriptions.push(channel);
    }

    function _isDuplicate(message) {
        // CRITICAL: Dedup on alertId + event name, NOT Ably message.id
        // Ably message.id is unique per channel, so the same alert published
        // to alerts:ou00 and alerts:pc00 would have DIFFERENT message.ids.
        // Users assigned to both stores must see the alert only once.
        var dedupKey = message.data.alertId + ':' + message.name;
        if (_processedMessageIds.has(dedupKey)) return true;
        _processedMessageIds.add(dedupKey);
        return false;
    }
})();
```

#### Example 3: Chat Toast Migration Pattern

**Why this example**: The chat migration (PRD Feature 8) must preserve all existing behaviors while delegating rendering. This shows the exact integration seam.

```javascript
// chat-notifications.js - MODIFIED section
// BEFORE (current):
_showToast: function(threadData) {
    // Direct DOM manipulation or toastr call
    toastr.info(messageHtml, titleHtml, {
        timeOut: 0,
        onclick: function() { self._openThread(threadData.threadId); }
    });
}

// AFTER (migrated):
_showToast: function(threadData) {
    // Delegate rendering to NotificationManager
    NotificationManager.enqueue({
        id: 'chat-' + threadData.threadId + '-' + Date.now(),
        type: 'chat',
        severity: 'chat',
        displayType: 'toast',
        title: threadData.customerName,
        body: threadData.preview,
        onClick: function() { self._openThread(threadData.threadId); }
        // No alertId = no DB acknowledgment tracking
    });
    // Sound, browser notification, badge update still handled HERE
    this._playChime();
    this._showBrowserNotification(threadData);
    this._updateBadge();
}
```

#### Example 4: Test Pattern

**Why this example**: Shows the expected unit test structure for the service layer.

```php
// tests/Unit/Feature/SystemAlerts/SystemAlertServiceTest.php
class SystemAlertServiceTest extends TestCase
{
    public function test_createAlert_publishes_to_ably_for_all_stores()
    {
        $mockRepo = $this->createMock(SystemAlertRepository::class);
        $mockAckRepo = $this->createMock(AcknowledgmentRepository::class);
        $mockPublisher = $this->createMock(AlertAblyPublisher::class);

        $mockRepo->expects($this->once())
            ->method('create')
            ->willReturn(new SystemAlert(['id' => 1, 'targetType' => 'all']));

        $mockPublisher->expects($this->once())
            ->method('publishCreated')
            ->with($this->callback(function($alert) {
                return $alert->targetType === 'all';
            }));

        $service = new SystemAlertService($mockRepo, $mockAckRepo, $mockPublisher);
        $result = $service->createAlert([
            'title' => 'Test Alert',
            'body' => '<p>Test body</p>',
            'severity' => 'info',
            'displayType' => 'banner',
            'targetType' => 'all',
        ], 28); // userId=28

        $this->assertInstanceOf(SystemAlert::class, $result);
    }

    public function test_updateAlert_resets_all_acknowledgments()
    {
        $mockRepo = $this->createMock(SystemAlertRepository::class);
        $mockAckRepo = $this->createMock(AcknowledgmentRepository::class);
        $mockPublisher = $this->createMock(AlertAblyPublisher::class);

        $mockAckRepo->expects($this->once())
            ->method('deleteAllForAlert')
            ->with(1);

        $mockPublisher->expects($this->once())
            ->method('publishUpdated');

        $service = new SystemAlertService($mockRepo, $mockAckRepo, $mockPublisher);
        $service->updateAlert(1, ['title' => 'Updated'], 28);
    }
}
```

## Runtime View

### Primary Flow: Admin Creates Alert

1. Admin navigates to BuyerKiosk > System Alerts page
2. Admin clicks "Create Alert" button, modal opens with Syncfusion RTE
3. Admin fills form (title, body, severity, display type, target, optional CTA, optional expiry)
4. Admin submits form
5. Server validates input, sanitizes HTML body, creates record in `systemAlerts`
6. If `targetType='specific'`, inserts rows into `systemAlertStoreTargets`
7. Server publishes via `AlertAblyPublisher` to appropriate channels
8. Response returned to admin, grid refreshes

```mermaid
sequenceDiagram
    actor Admin
    participant Modal as Alert Form Modal
    participant API as SystemAlertsAdminController
    participant Svc as SystemAlertService
    participant Repo as SystemAlertRepository
    participant Pub as AlertAblyPublisher
    participant Ably as Ably Channels

    Admin->>Modal: Fill form + Submit
    Modal->>API: POST /api/system-alerts/admin/alerts
    API->>API: Validate + Sanitize HTML
    API->>Svc: createAlert(data, userId)
    Svc->>Repo: create(alertData)
    Repo-->>Svc: SystemAlert{id:42}

    alt targetType = 'specific'
        Svc->>Repo: createStoreTargets(42, ['ou00','pc00'])
    end

    Svc->>Pub: publishCreated(alert)

    alt targetType = 'all'
        Pub->>Ably: publish('alerts:global', 'alert:created', payload)
    else targetType = 'specific'
        Pub->>Ably: publish('alerts:ou00', 'alert:created', payload)
        Pub->>Ably: publish('alerts:pc00', 'alert:created', payload)
    end

    Svc-->>API: SystemAlert
    API-->>Modal: { alert: {...}, message: "Alert created" }
    Modal->>Admin: Success notification + Grid refresh
```

### Secondary Flow: Staff Sees and Acknowledges Alert

1. Staff member's browser receives Ably event on subscribed channel
2. `AlertAblySync` deduplicates and passes to `NotificationManager`
3. `NotificationManager` checks queue (max 3 visible), renders banner or toast
4. Staff reads alert, clicks Dismiss button
5. `NotificationManager` removes from display, POSTs acknowledgment
6. Next queued notification (if any) is displayed

```mermaid
sequenceDiagram
    participant Ably as Ably Channel
    participant Sync as AlertAblySync
    participant NM as NotificationManager
    participant Banner as BannerRenderer
    participant API as StaffController
    participant DB as kiosk_buykiosk

    Ably->>Sync: alert:created { alertId:42, severity:'critical', displayType:'banner' }
    Sync->>Sync: Dedup check (msgId)
    Sync->>NM: enqueue(notification)
    NM->>NM: Check visible count < 3
    NM->>Banner: render(notification)
    Banner->>Banner: Insert banner above page header

    Note over Banner: Staff reads alert content

    Banner->>NM: dismiss(notificationId, alertId=42)
    NM->>NM: Remove from _visibleItems
    NM->>API: POST /api/system-alerts/42/acknowledge
    API->>DB: INSERT INTO systemAlertAcknowledgments
    NM->>NM: _processQueue() - show next if queued
    NM->>NM: localStorage broadcast for multi-tab sync
```

### Tertiary Flow: Page Load Alert Fetch

1. Workbook page loads, JS initializes
2. `AlertAblySync` subscribes to Ably channels (global + per-assignment)
3. Simultaneously, fetch pending alerts from API (DB is source of truth)
4. `NotificationManager` displays up to 3, queues the rest
5. Ably events handle any new alerts arriving after initial load

```mermaid
sequenceDiagram
    participant Page as Workbook Page Load
    participant Sync as AlertAblySync
    participant NM as NotificationManager
    participant API as StaffController
    participant DB as kiosk_buykiosk
    participant Ably as Ably Channels

    Page->>Sync: init(ablyClient, userStoreAssignments)
    Sync->>Ably: subscribe('alerts:global')
    Sync->>Ably: subscribe('alerts:ou00')
    Sync->>Ably: subscribe('alerts:pc00')

    Page->>API: GET /api/system-alerts/pending
    API->>DB: SELECT alerts not acknowledged by user, targeted to user's stores
    DB-->>API: [alert1, alert2, alert3, alert4]
    API-->>Page: { alerts: [...] }

    Page->>NM: enqueue(alert1) - critical banner
    Page->>NM: enqueue(alert2) - warning toast
    Page->>NM: enqueue(alert3) - info banner
    Page->>NM: enqueue(alert4) - info toast (queued, max 3 visible)

    Note over NM: 3 visible, 1 queued

    Note over Ably: Later - new alert arrives
    Ably->>Sync: alert:created { alertId:99 }
    Sync->>NM: enqueue(newAlert) - added to queue
```

### Error Handling

- **Invalid input (Create/Update)**: Return 400 JSON `{ error: true, message: "Validation error", details: { field: "reason" } }`. Form highlights invalid fields.
- **Permission denied**: Return 403 JSON `{ error: true, message: "Not authorized" }`. Redirect to login if session expired.
- **Alert not found**: Return 404 JSON `{ error: true, message: "Alert not found" }`.
- **Ably publish failure**: Log error, alert is still persisted in DB. Staff will fetch on next page load (DB is source of truth per PRD decision).
- **Ably connection lost (client)**: `AlertAblySync` monitors connection state. On reconnect (`connected` after `disconnected`/`suspended`), immediately calls `GET /api/system-alerts/pending` to backfill any alerts published during the disconnect. Merges with existing queue, deduplicated by alertId. Shows reconnection indicator if disconnect lasted >5 seconds.
- **Duplicate acknowledgment**: `UNIQUE(alertId, userId)` constraint - catch duplicate key exception, return success (idempotent).
- **Expired alert fetch**: `GET /pending` filters out expired alerts server-side. No expired alerts reach the client.

### Complex Logic

#### Algorithm: Targeting Resolution (Pending Alerts Query)

```
ALGORITHM: Get Pending Alerts for User
INPUT: userId
OUTPUT: array of PendingAlert

1. GET user's store assignments:
   SELECT typeNum FROM kiosk_users.userStoreAssignments
   WHERE userId = :userId AND isActive = 1
   -> userStores = ['ou00', 'pc00']

2. QUERY alerts targeted to this user (union of global + specific):
   SELECT sa.* FROM systemAlerts sa
   WHERE sa.isActive = 1
   AND (sa.expiresAt IS NULL OR sa.expiresAt > NOW())
   AND sa.id NOT IN (
       SELECT alertId FROM systemAlertAcknowledgments WHERE userId = :userId
   )
   AND (
       sa.targetType = 'all'
       OR EXISTS (
           SELECT 1 FROM systemAlertStoreTargets sast
           WHERE sast.alertId = sa.id
           AND sast.typeNum IN (:userStores)
       )
   )
   ORDER BY
       FIELD(sa.severity, 'critical', 'warning', 'info'),
       sa.createdAt DESC

3. RETURN results as PendingAlert DTOs
```

#### Algorithm: Acknowledgment Dashboard Stats

```
ALGORITHM: Get Acknowledgment Stats for Alert
INPUT: alertId
OUTPUT: { totalTargeted, totalAcknowledged, percentAcknowledged, acknowledged[], pending[] }

1. GET alert with targeting:
   SELECT * FROM systemAlerts WHERE id = :alertId

2. IF targetType = 'all':
   totalTargeted = COUNT(DISTINCT userId) FROM kiosk_users.userStoreAssignments WHERE isActive = 1
   ELSE:
   GET targetStores from systemAlertStoreTargets WHERE alertId = :alertId
   totalTargeted = COUNT(DISTINCT userId) FROM kiosk_users.userStoreAssignments
     WHERE typeNum IN (:targetStores) AND isActive = 1

3. GET acknowledged users:
   SELECT saa.userId, u.firstName, u.lastName, usa.typeNum, saa.dismissedAt
   FROM systemAlertAcknowledgments saa
   JOIN kiosk_users.users u ON saa.userId = u.id
   JOIN kiosk_users.userStoreAssignments usa ON u.id = usa.userId AND usa.isActive = 1
   WHERE saa.alertId = :alertId

4. GET pending users (targeted minus acknowledged):
   (All targeted users) MINUS (acknowledged users)

5. CALCULATE percentAcknowledged = (totalAcknowledged / totalTargeted) * 100
```

## Deployment View

- **Environment**: Single-server deployment (local dev via ngrok, production via Envoyer)
- **Configuration**: No new env vars needed. Uses existing `ABLY_KEY` env var.
- **Dependencies**: Ably SDK (already included), Syncfusion EJ2 (already included), Redis (already available)
- **Migration Sequence**:
  1. Run `php userfrosting/conductor run` to create tables and permissions
  2. Deploy code (routes, controllers, services, templates, JS)
  3. No feature flag needed - admin page is permission-gated, alerts are empty until first one is created
- **Rollback Strategy**: Drop 3 new tables, remove permission entries from `uf_authorize_group`, revert code. Chat notifications revert to direct rendering.
- **Lifecycle Note**: Alerts are never hard-deleted. Deactivation + optional expiry cover all lifecycle needs. This preserves full audit history for compliance tracking.

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: Route -> Controller -> Service -> Repository
  relevance: CRITICAL
  why: "Core architecture pattern for all API endpoints"

- pattern: AblyPublishThrottle for rate-limited publishing
  relevance: HIGH
  why: "Must use for all Ably publishes to respect 40 msg/sec limit"

- pattern: checkAccessAndReturnStoreObject() for route auth
  relevance: HIGH
  why: "Standard permission + store scoping helper"

- pattern: JSON migration files for schema changes
  relevance: CRITICAL
  why: "Only way to modify database schema"

# New patterns created
- pattern: NotificationManager unified rendering
  relevance: HIGH
  why: "New pattern for all toast/banner rendering going forward"

- pattern: Multi-channel Ably subscription with dedup
  relevance: MEDIUM
  why: "Pattern for subscribing to multiple channels with message deduplication"
```

### System-Wide Patterns

- **Security**: Session auth + CSRF on all mutating endpoints. `uri_bkadmin` permission for admin operations. HTML body sanitized server-side (strip `<script>`, event handlers, etc.). XSS escaped on client for title fields.
- **Error Handling**: Try-catch in controllers with JSON error responses. Ably publish failures logged but non-fatal (DB is source of truth). Client-side errors logged to console, graceful degradation.
- **Performance**: Pending alerts query indexed on `(isActive, expiresAt)`. Acknowledgment lookup indexed on `(alertId, userId)`. Client-side dedup set prevents redundant API calls. Max 3 visible items limits DOM overhead.
- **Logging**: PHP `error_log()` for server errors. `console.log('[NotificationManager]')` prefix for client debugging. Ably publish failures logged with alert ID and channel.

### Implementation Patterns

#### State Management (Client-Side)
- `NotificationManager` maintains `_visibleItems[]` and `_queue[]` in module scope (IIFE pattern)
- `_acknowledgedIds` Set synced across tabs via `localStorage` events
- `AlertBellDropdown` fetches history on dropdown open (not cached - always fresh from server)

#### Alert Bell Dropdown Behavior
- Bell icon in `.header-actions` shows unread badge count (system alerts only, not chat)
- Badge count updates in realtime via Ably events and decrements on acknowledgment
- Clicking bell opens Bootstrap dropdown with two sections:
  - **Pending** (top): Unacknowledged alerts sorted by severity then date. Each shows title, severity badge, timestamp.
  - **Recently Dismissed** (bottom): Last 10 acknowledged alerts with dimmed styling and "dismissed" timestamp.
- Clicking a pending alert **expands it inline** showing full body (rich text rendered) and CTA button if present
- A separate **"Dismiss"** button in the expanded view acknowledges the alert (removes from pending, moves to dismissed)
- Clicking a CTA link within the expanded alert opens the link AND acknowledges the alert
- If the same alert is visible as a banner/toast AND in the bell, dismissing from either location removes it from both (single acknowledgment)
- Bell dropdown fetches fresh data from `/api/system-alerts/history` on each open (no stale cache)

#### Multi-Tab Deduplication
- When user dismisses an alert in Tab A:
  1. `localStorage.setItem('alert-dismissed-' + alertId, Date.now())`
  2. Tab B listens to `window.addEventListener('storage', ...)` event
  3. Tab B removes the alert from its `NotificationManager` queue/visible
- On page load, check `localStorage` for recently dismissed alerts before displaying

## Architecture Decisions

- [x] ADR-1 **Central DB for alert storage**: All 3 tables (`systemAlerts`, `systemAlertStoreTargets`, `systemAlertAcknowledgments`) go in `kiosk_buykiosk` central database, NOT per-store databases.
  - Rationale: Alerts can target multiple stores or all stores. Storing in central DB avoids cross-database joins and data duplication. Acknowledgments need to be global per user, not per store.
  - Trade-offs: Slightly more complex queries for store-specific filtering (requires JOIN to targets table). Central DB gets more write load from acknowledgments.
  - User confirmed: 2026-02-27

- [x] ADR-2 **Dedicated Ably channel prefix `alerts:{typeNum}`**: New channels separate from existing store channels (e.g., `alerts:ou00` not `ou00`).
  - Rationale: Clean separation of concerns. Alert subscriptions don't interfere with existing store data sync. Can be independently managed in Ably dashboard. Matches PRD decision for "dedicated Ably alerts channels."
  - Trade-offs: Additional Ably channels (one per store + one global). Users must subscribe to multiple channels.
  - User confirmed: 2026-02-27

- [x] ADR-3 **Unified NotificationManager replaces direct toast rendering**: Single JS module manages ALL notification display (system alert banners, system alert toasts, chat toasts). Chat module delegates rendering but keeps its own logic (sound, browser notifications, consolidation).
  - Rationale: PRD Feature 8 requires unified toast framework. Single queue manager enforces max 3 visible globally. Eliminates duplicate toast rendering code.
  - Trade-offs: Chat notifications gain a dependency on NotificationManager. Slightly more complex initialization order. Chat migration requires careful testing.
  - User confirmed: 2026-02-27

- [x] ADR-4 **No separate analytics/tracking table**: Acknowledgment dashboard queries use the `systemAlertAcknowledgments` table directly with JOINs to `userStoreAssignments` and `users`. No separate analytics aggregation table.
  - Rationale: Alert volume is low (admins create maybe 1-5 per day). User base per store is small (5-20 staff). Simple JOINs are fast enough without denormalization.
  - Trade-offs: If alert or user volume grows significantly, dashboard queries may slow. Can add materialized views later if needed.
  - User confirmed: 2026-02-27

- [x] ADR-5 **Syncfusion RichTextEditor for alert body**: Use EJ2 RTE component in the create/edit modal per CLAUDE.md requirement to prefer Syncfusion.
  - Rationale: CLAUDE.md mandates Syncfusion over custom. RTE provides formatting (bold, italic, lists, links) without raw HTML editing. Server sanitizes output.
  - Trade-offs: Syncfusion RTE adds JS bundle weight. Must handle hidden container initialization gotcha (init when modal is visible).
  - User confirmed: 2026-02-27

- [x] ADR-6 **`alerts:global` channel for all-stores alerts**: Single global channel instead of publishing to every store's channel for `targetType='all'`.
  - Rationale: Publishing to every store channel would be N publishes (one per store) vs 1 publish to global. Reduces Ably rate limit consumption. All clients subscribe to global channel regardless.
  - Trade-offs: All users must subscribe to one extra channel. Global channel could get noisy if many global alerts are created.
  - User confirmed: 2026-02-27

## Quality Requirements

- **Performance**:
  - Page load impact: < 100ms added latency for pending alerts API call (typical: <10 active alerts)
  - Ably delivery: < 2 seconds from publish to client render
  - Admin dashboard: < 500ms for acknowledgment stats query (up to 1000 users)
  - Alert CRUD operations: < 300ms response time

- **Usability**:
  - Banners push page content down (not overlay) - no content obscured
  - Toasts appear bottom-right (consistent with existing chat toast position) with dismiss button always visible, stacked vertically with 10px gap
  - Bell dropdown shows alert history with expand/collapse
  - RTE provides bold, italic, underline, ordered list, unordered list, link, and undo/redo
  - Keyboard accessible: Dismiss via Escape key, Tab navigation through alerts

- **Security**:
  - HTML body sanitized server-side: strip `<script>`, `<iframe>`, `onerror`, `onclick`, and all JS event handlers
  - CTA URL validated server-side: must be `https://` or relative path only (no `javascript:`, `data:`, `ftp:` protocols). Max 500 chars. Sanitized with `filter_var(FILTER_VALIDATE_URL)`.
  - CSRF token on all POST/PUT/DELETE endpoints
  - `uri_bkadmin` permission required for all admin endpoints
  - Staff endpoints verify active session and store assignment

- **Reliability**:
  - DB is source of truth - Ably failures don't lose alerts (page load fetches from DB)
  - Duplicate acknowledgments are idempotent (UNIQUE constraint + catch)
  - Multi-tab dismissal sync via localStorage events
  - Expired alerts auto-filtered server-side (no client-side expiry logic needed)

## Risks and Technical Debt

### Out of Scope for v1
- **Alert Sound Notification** (PRD Could Have Feature 10): Critical alert audio chime. Can be added later to NotificationManager without architectural changes.
- **Alert Pinning** (PRD Could Have Feature 11): Non-dismissible pinned alerts. Requires `isPinned` column addition and queue priority override. Deferred.

### Known Technical Issues
- Ably rate limit (40 msg/sec) is shared across all features. High-volume alert creation could impact chat delivery. Mitigated by expected low alert volume.
- `AblyPublishThrottle` uses Redis - if Redis is down, Ably publishes fail silently. DB source-of-truth pattern mitigates this.

### Technical Debt
- `ChatNotifications` currently uses mixed rendering patterns (toastr + custom DOM). Migration to NotificationManager adds a temporary dual-rendering phase during development.
- `WorkbookToast` remains separate - could be migrated to NotificationManager in a future iteration but is out of scope for v1.

### Implementation Gotchas

- **Syncfusion RTE in modal**: RTE MUST be initialized AFTER the Bootstrap modal is fully shown (`shown.bs.modal` event). Initializing while hidden causes `Cannot read properties of null (reading 'lastElementChild')`. Destroy and recreate on each modal open.

- **PDO named params**: Cannot reuse `:typeNum` in the same query (e.g., in both a WHERE and a subquery). Use `:typeNum1`, `:typeNum2` and bind both with the same value.

- **Ably init timing**: `AlertAblySync` must initialize AFTER `WorkbookAblySync` establishes the Ably connection. Use the existing `window.workbookAbly` global or listen for a ready event.

- **Twig vs Handlebars**: Templates in `workspace/partials/` that need client-side rendering must wrap Handlebars in `{% raw %}{% endraw %}`. Banner/bell templates use this pattern.

- **Toast positioning vs banner positioning**: Banners go ABOVE page header (push content down, `z-index: 1031` > header's 1030). Toasts go bottom-right fixed (`z-index: 1090`, matching existing chat toast position at `bottom: 80px; right: 20px`). These are different containers. Toasts stack vertically with 10px gap and never auto-dismiss.

- **Multi-tab localStorage sync**: `storage` event only fires in OTHER tabs, not the originating tab. The originating tab handles dismissal directly; the `storage` listener handles cross-tab sync.

- **Migration ID stability**: Do NOT change the `description` field in migration JSON after deployment - it changes the MD5 hash and creates a new operation ID, causing the migration to run again.

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Admin Creates Global Alert**
```gherkin
Given: Admin user with uri_bkadmin permission is on System Alerts page
And: No existing alerts
When: Admin clicks "Create Alert" and fills form with:
  | Field       | Value                    |
  | Title       | Store Closing Early      |
  | Body        | <p>Closing at 5pm today</p> |
  | Severity    | warning                  |
  | Display     | banner                   |
  | Target      | All Stores               |
And: Admin submits the form
Then: Alert record created in systemAlerts table with isActive=1
And: Ably message published to 'alerts:global' channel with event 'alert:created'
And: Success message displayed to admin
And: Grid refreshes showing new alert
```

**Scenario 2: Staff Receives and Dismisses Banner**
```gherkin
Given: Staff user with stores ['ou00','pc00'] is on Workbook page
And: NotificationManager initialized with 0 visible items
When: Ably delivers alert:created event on 'alerts:global' channel
And: Alert has displayType='banner' and severity='warning'
Then: Banner appears above page header pushing content down
And: Banner shows title, body, severity icon, and Dismiss button
When: Staff clicks Dismiss button
Then: Banner slides out of view
And: POST request sent to /api/system-alerts/{id}/acknowledge
And: Acknowledgment record created in systemAlertAcknowledgments
And: localStorage updated for multi-tab sync
And: Next queued notification (if any) is displayed
```

**Scenario 3: Max 3 Visible with Queue**
```gherkin
Given: Staff user has 5 pending alerts (2 critical banners, 2 warning toasts, 1 info toast)
When: Page loads and fetches pending alerts
Then: 3 highest-priority items are displayed (2 critical banners + 1 warning toast)
And: 2 remaining items (1 warning toast + 1 info toast) are queued
When: Staff dismisses one visible item
Then: Next queued item is automatically displayed
And: Visible count remains at 3
```

**Scenario 4: Edit Resets Acknowledgments**
```gherkin
Given: Alert ID 42 exists with 15 acknowledgments
When: Admin edits alert 42 (changes title)
Then: All 15 acknowledgment records for alert 42 are deleted
And: Ably message published with event 'alert:updated'
And: All staff who previously dismissed alert 42 will see it again
And: Acknowledgment dashboard shows 0/15 acknowledged
```

**Scenario 5: Store-Targeted Alert**
```gherkin
Given: Admin creates alert targeting stores ['ou00', 'pc00']
When: Alert is published
Then: Ably message sent to 'alerts:ou00' channel
And: Ably message sent to 'alerts:pc00' channel
And: No message sent to 'alerts:global' channel
And: User assigned to 'ou00' sees the alert
And: User assigned to 'cm00' does NOT see the alert
```

**Scenario 6: Multi-Tab Dismissal Sync**
```gherkin
Given: Staff has Workbook open in Tab A and Tab B
And: Both tabs show the same banner alert
When: Staff dismisses the alert in Tab A
Then: Tab A removes the banner and sends acknowledgment API call
And: Tab A writes 'alert-dismissed-42' to localStorage
And: Tab B's storage event listener fires
And: Tab B removes the same banner from display
And: Tab B does NOT send a duplicate acknowledgment API call
```

**Scenario 7: Chat Toast via Unified Framework**
```gherkin
Given: NotificationManager is initialized
And: ChatNotifications has been migrated to use NotificationManager
When: New chat message arrives via Ably
Then: ChatNotifications calls NotificationManager.enqueue() with type='chat'
And: Toast appears in top-right position (if under max 3 visible)
And: Chat chime sound plays (handled by ChatNotifications, not NotificationManager)
And: Browser notification shown (handled by ChatNotifications)
And: Clicking toast opens chat thread (onClick callback from ChatNotifications)
And: No DB acknowledgment tracking for chat toasts
```

### Test Coverage Requirements

- **Business Logic**: Alert CRUD validation, targeting resolution, acknowledgment tracking, expiry filtering, acknowledgment reset on edit
- **User Interface**: Banner rendering and positioning, toast rendering, bell dropdown, RTE integration, max 3 visible enforcement
- **Integration Points**: Ably publish/subscribe, multi-channel subscription, message deduplication, CSRF validation
- **Edge Cases**: Multi-tab sync, duplicate acknowledgments, expired alerts, empty state, concurrent alert creation
- **Performance**: Pending alerts query with indexes, acknowledgment dashboard with many users
- **Security**: XSS in alert body, CSRF validation, permission checks, HTML sanitization

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| System Alert | A broadcast message created by admins to notify staff across stores | Core entity of this feature |
| Acknowledgment | Record that a user has dismissed/seen a specific alert | Tracks compliance in systemAlertAcknowledgments |
| Banner | Full-width notification displayed above page content, pushes content down | displayType='banner' |
| Toast | Small notification card in top-right corner | displayType='toast' |
| Severity | Alert importance level: info, warning, critical | Determines priority in queue and visual styling |
| Targeting | Whether alert goes to all stores or specific stores | targetType='all' or 'specific' |
| CTA | Call-to-Action button with link embedded in an alert | Optional ctaLabel + ctaUrl |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| NotificationManager | Client-side JS module that manages all notification rendering and queuing | Unified framework replacing direct toast rendering |
| AlertAblySync | Client-side JS module subscribing to Ably alert channels | Bridges Ably events to NotificationManager |
| AlertAblyPublisher | Server-side PHP class publishing alert events via Ably | Uses AblyPublishThrottle for rate limiting |
| AblyPublishThrottle | Redis-based rate limiter for Ably publishes (40 msg/sec) | Existing infrastructure constraint |
| typeNum | Store identifier string (e.g., 'ou00', 'pc00') | Used for store targeting and channel naming |

### API Terms

| Term | Definition | Context |
|------|------------|---------|
| Pending Alerts | Active, non-expired alerts that a user has not yet acknowledged | GET /api/system-alerts/pending |
| Alert History | All alerts targeted to a user (including dismissed) | GET /api/system-alerts/history |
| Ack Dashboard | Admin view showing who has/hasn't acknowledged an alert | GET /api/system-alerts/admin/alerts/:id/acknowledgments |
