# Solution Design Document: Staff Chat Backend

## Validation Checklist

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

---

## Constraints

**CON-1 Framework & Language Requirements**
- PHP 8.x with Slim 2.6.2 framework
- PSR-4 autoloading via Composer (`BuyerKiosk\*` namespace)
- MySQL databases with multi-store isolation
- Column naming: camelCase (e.g., `messageId`, `channelId`)
- Table naming: snake_case with feature prefix (e.g., `staff_chat_channels`)

**CON-2 Infrastructure Requirements**
- Must use existing Ably for real-time messaging (already configured)
- Must use existing Firebase for push notifications (infrastructure ready, FCM sending pending)
- Per-store databases named `kiosk_{typeNum}` (e.g., `kiosk_ou00`)
- Central database `kiosk_users` for auth/user management
- Local filesystem for media storage at `/var/www/buyerkiosk/uploads/`

**CON-3 Authentication & Security Requirements**
- Web users: Session-based auth via `$app->user`
- Mobile users: JWT Bearer tokens via `HybridAuthMiddleware`
- Store access: `checkStoreGroup($typeNum)` for web, `StoreAccessMiddleware` for mobile
- Existing permission hooks: `uri_store_settings`, `uri_daybook_edit`, `uri_owner`

**CON-4 Performance Targets**
- Message delivery: <200ms from send to Ably publish
- Read receipts: <500ms from API call to Ably broadcast
- Light volume assumption: <100 messages/day/store
- Rate limits: 30 msgs/min/user, 10 uploads/min/user

---

## Implementation Context

### Required Context Sources

```yaml
# Internal documentation and patterns
- doc: CLAUDE.md
  relevance: HIGH
  why: "Project conventions, commands, and architectural constraints"

- doc: docs/specs/024-staff-chat-backend/product-requirements.md
  relevance: CRITICAL
  why: "Complete PRD with all requirements, edge cases, and technical clarifications"

# Existing patterns to follow
- file: userfrosting/src/BuyerKiosk/Chat/Controllers/ChatApiController.php
  relevance: HIGH
  why: "Existing two-way SMS chat controller pattern (940+ lines, comprehensive)"

- file: userfrosting/src/BuyerKiosk/Chat/Events/ChatAblyPublisher.php
  relevance: HIGH
  why: "Existing Ably publishing pattern for chat events"

- file: userfrosting/src/BuyerKiosk/Chat/Models/ChatMessage.php
  relevance: HIGH
  why: "Entity model pattern with fromRow(), toArray(), constants"

- file: userfrosting/src/BuyerKiosk/Workbook/NoteReaction.php
  relevance: MEDIUM
  why: "Reaction pattern (add/remove with UNIQUE constraint)"

- file: userfrosting/src/BuyerKiosk/MobileApi/Middleware/StoreAccessMiddleware.php
  relevance: HIGH
  why: "Mobile API authorization pattern with AuthContext"

- file: userfrosting/src/BuyerKiosk/MobileApi/Controllers/MobileAuthController.php
  relevance: MEDIUM
  why: "JWT authentication and device token registration patterns"

# External documentation
- url: https://ably.com/docs/api/rest-sdk
  relevance: MEDIUM
  why: "Ably REST SDK for PHP message publishing"

- url: https://firebase.google.com/docs/cloud-messaging
  relevance: MEDIUM
  why: "Firebase Cloud Messaging for push notifications"
```

### Implementation Boundaries

- **Must Preserve**:
  - Existing user permission system (checkAccess, checkStoreGroup)
  - Multi-store database isolation (dbConnectByName pattern)
  - JWT authentication for mobile apps
  - Ably channel naming convention (`{typeNum}` for store events)

- **Can Modify**:
  - Add new tables to store databases
  - Add new routes under `/api/` and `/:typeNum/`
  - Create new controllers, models, services in `src/BuyerKiosk/StaffChat/`

- **Must Not Touch**:
  - Existing SMS Chat system (`src/BuyerKiosk/Chat/`)
  - Core authentication middleware
  - User/employee table structures

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    subgraph "BuyerKiosk Staff Chat"
        API[Staff Chat API]
        Ably[Ably Publisher]
        FCM[Firebase Push Service]
    end

    WebUser[Workbook User] --> API
    MobileUser[Team/Live App User] --> API

    API --> StoreDB[(Store Database)]
    API --> CentralDB[(Central Database)]
    API --> Ably
    API --> FCM

    Ably --> AblyCloud[Ably Cloud]
    FCM --> FirebaseCloud[Firebase Cloud]

    AblyCloud --> WebUser
    AblyCloud --> MobileUser
    FirebaseCloud --> MobileUser

    WorkbookEvents[Workbook Events] --> API
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Workbook Web Interface"
    type: HTTPS
    format: REST JSON
    authentication: Session-based ($app->user)
    routes: "/:typeNum/api/staff-chat/*"
    data_flow: "Channel CRUD, message operations, reactions"

  - name: "Mobile App API"
    type: HTTPS
    format: REST JSON
    authentication: JWT Bearer token
    routes: "/api/mobile/staff-chat/:typeNum/*"
    data_flow: "Same operations as web, mobile-optimized responses"

  - name: "Workbook Event Webhooks"
    type: Internal PHP
    format: Method calls
    authentication: Internal (same process)
    data_flow: "Task/Note creation triggers system messages"

# Outbound Interfaces
outbound:
  - name: "Ably Real-time"
    type: HTTPS (REST SDK)
    format: JSON
    authentication: API Key ($_ENV['ABLY_KEY'])
    channel: "{typeNum}" or "chat:{typeNum}:{channelId}"
    events: "message.created, message.updated, message.deleted, reaction.added, read.updated"
    criticality: HIGH

  - name: "Firebase Cloud Messaging"
    type: HTTPS
    format: FCM v1 API
    authentication: Service Account (pending implementation)
    data_flow: "Push notifications for mentions, new messages"
    criticality: MEDIUM

# Data Interfaces
data:
  - name: "Store Database"
    type: MySQL
    connection: "dbConnectByName($store->getDbName())"
    tables: "staff_chat_channels, staff_chat_messages, staff_chat_reactions, staff_chat_read_receipts, staff_chat_channel_members, staff_chat_attachments, staff_chat_mentions, staff_chat_audit_log, staff_chat_event_outbox"
    data_flow: "All chat content storage"

  - name: "Central Database"
    type: MySQL
    connection: "dbConnectByName('kiosk_users')"
    tables: "userDeviceTokens, userNotificationPreferences"
    data_flow: "Push notification tokens and preferences"

  - name: "File Storage"
    type: Local Filesystem
    path: "/var/www/buyerkiosk/uploads/chat/{typeNum}/{channelId}/"
    data_flow: "Image attachments (JPEG, PNG, GIF)"
```

### Project Commands

```bash
# Environment Setup
cd userfrosting && composer install

# Testing Commands
./test.sh                           # Run all tests
./test.sh --testsuite unit          # Run unit tests only
./test.sh --coverage                # Run with coverage report
./test.sh --stan                    # Run tests + PHPStan analysis

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/StaffChat/

# Database Migrations
php userfrosting/conductor run      # Apply migrations

# Development
php userfrosting/conductor build-css           # CSS build
php userfrosting/conductor build-css --watch   # Watch mode

# Deployment
./deploy.sh                         # Test + deploy
```

---

## Solution Strategy

### Architecture Pattern: Layered Service Architecture

**Pattern Selection**: Feature-based modular architecture following existing BuyerKiosk patterns.

```
┌─────────────────────────────────────────────────────────────┐
│                      Routes Layer                            │
│  (routes/staff-chat/*.php - URL routing & middleware)        │
├─────────────────────────────────────────────────────────────┤
│                    Controller Layer                          │
│  (Controllers/*.php - Request handling, validation, auth)   │
├─────────────────────────────────────────────────────────────┤
│                     Service Layer                            │
│  (Services/*.php - Business logic, channel access, etc.)    │
├─────────────────────────────────────────────────────────────┤
│                      Model Layer                             │
│  (Models/*.php - Entity objects, data transformation)       │
├─────────────────────────────────────────────────────────────┤
│                      Events Layer                            │
│  (Events/*.php - Ably publishing, push notifications)       │
├─────────────────────────────────────────────────────────────┤
│                   Repository Layer                           │
│  (Repositories/*.php - Database access, queries)            │
└─────────────────────────────────────────────────────────────┘
```

**Integration Approach**:
- New feature module under `src/BuyerKiosk/StaffChat/`
- Follows existing Chat (SMS) and Workbook patterns
- Reuses Ably infrastructure with new event types
- Extends notification preferences for chat categories

**Justification**:
- Matches existing codebase structure (consistency)
- Clear separation of concerns (testability)
- Existing patterns for Ably, notifications, auth (minimal new learning)
- Light volume means no need for complex event sourcing

**Key Decisions**:
1. Single controller for all endpoints (consistent with ChatApiController pattern)
2. Service layer for channel access logic (complex authorization rules)
3. Repository pattern for database queries (testability)
4. Event publisher pattern for Ably/FCM (decoupled from business logic)

---

## Building Block View

### Components

```mermaid
graph TB
    subgraph "Routes"
        WebRoutes[staff-chat/api.php]
        MobileRoutes[groups/mobile-staff-chat.php]
    end

    subgraph "Controllers"
        WebController[StaffChatApiController]
        MobileController[MobileStaffChatController]
    end

    subgraph "Services"
        ChannelService[ChannelAccessService]
        MessageService[MessageService]
        NotificationService[ChatNotificationService]
    end

    subgraph "Models"
        Channel[Channel]
        Message[Message]
        Reaction[Reaction]
        ReadReceipt[ReadReceipt]
    end

    subgraph "Events"
        AblyPublisher[StaffChatAblyPublisher]
        PushService[ChatPushNotificationService]
    end

    subgraph "Repositories"
        ChannelRepo[ChannelRepository]
        MessageRepo[MessageRepository]
        MemberRepo[ChannelMemberRepository]
    end

    WebRoutes --> WebController
    MobileRoutes --> MobileController

    WebController --> ChannelService
    WebController --> MessageService
    MobileController --> ChannelService
    MobileController --> MessageService

    MessageService --> NotificationService
    MessageService --> MessageRepo
    MessageService --> AblyPublisher

    ChannelService --> ChannelRepo
    ChannelService --> MemberRepo

    NotificationService --> PushService
    NotificationService --> AblyPublisher

    ChannelRepo --> Channel
    MessageRepo --> Message
    MessageRepo --> Reaction
    MessageRepo --> ReadReceipt
```

### Directory Map

```
userfrosting/
├── src/BuyerKiosk/StaffChat/
│   ├── Controllers/
│   │   ├── StaffChatApiController.php      # NEW: Web/Workbook API endpoints
│   │   └── MobileStaffChatController.php   # NEW: Mobile API endpoints
│   ├── Services/
│   │   ├── ChannelAccessService.php        # NEW: Channel membership & authorization
│   │   ├── MessageService.php              # NEW: Message CRUD, mentions, audit
│   │   └── ChatNotificationService.php     # NEW: Notification orchestration
│   ├── Models/
│   │   ├── Channel.php                     # NEW: Channel entity
│   │   ├── Message.php                     # NEW: Message entity
│   │   ├── Reaction.php                    # NEW: Reaction entity
│   │   ├── ReadReceipt.php                 # NEW: Read receipt entity
│   │   └── ChannelMember.php               # NEW: Membership entity
│   ├── Repositories/
│   │   ├── ChannelRepository.php           # NEW: Channel database operations
│   │   ├── MessageRepository.php           # NEW: Message database operations
│   │   └── ChannelMemberRepository.php     # NEW: Membership operations
│   ├── Events/
│   │   ├── StaffChatAblyPublisher.php      # NEW: Ably event publishing
│   │   └── ChatPushNotificationService.php # NEW: FCM push notifications
│   └── Jobs/
│       ├── StaffChatOutboxWorkerJob.php    # NEW: Async Ably publish worker
│       └── StaffChatRetentionJob.php       # NEW: Message retention cleanup
├── routes/
│   ├── staff-chat/
│   │   └── api.php                         # NEW: Web API routes
│   └── groups/
│       └── mobile-staff-chat.php           # NEW: Mobile API routes
├── migrations/
│   └── input/
│       ├── 20260101_001_staff_chat_channels.json     # NEW
│       ├── 20260101_002_staff_chat_messages.json     # NEW
│       ├── 20260101_003_staff_chat_reactions.json    # NEW
│       ├── 20260101_004_staff_chat_read_receipts.json # NEW
│       ├── 20260101_005_staff_chat_channel_members.json # NEW
│       ├── 20260101_006_staff_chat_attachments.json  # NEW
│       ├── 20260101_007_staff_chat_mentions.json     # NEW
│       ├── 20260101_008_staff_chat_audit_log.json    # NEW
│       └── 20260101_009_staff_chat_event_outbox.json # NEW
└── tests/Unit/StaffChat/
    ├── Controllers/                        # NEW: Controller tests
    ├── Services/                           # NEW: Service tests
    ├── Models/                             # NEW: Model tests
    └── Repositories/                       # NEW: Repository tests
```

### Data Storage Changes

#### Database Schema

**Table: staff_chat_channels** (Store Database)
```sql
CREATE TABLE `staff_chat_channels` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `typeNum` varchar(10) NOT NULL,
  `name` varchar(100) NOT NULL,
  `description` text DEFAULT NULL,
  `accessLevel` enum('public','manager','owner') NOT NULL DEFAULT 'public',
  `isDefault` tinyint(1) NOT NULL DEFAULT 0,
  `retentionDays` int(10) unsigned DEFAULT NULL COMMENT 'NULL = forever',
  `createdByEmployeeId` int(10) unsigned DEFAULT NULL,
  `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  `updatedAt` timestamp NULL DEFAULT NULL ON UPDATE current_timestamp(),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_typeNum_name` (`typeNum`, `name`),
  KEY `idx_typeNum_accessLevel` (`typeNum`, `accessLevel`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Table: staff_chat_messages** (Store Database)
```sql
CREATE TABLE `staff_chat_messages` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `channelId` int(10) unsigned NOT NULL,
  `typeNum` varchar(10) NOT NULL,
  `senderEmployeeId` int(10) unsigned DEFAULT NULL COMMENT 'NULL = system message',
  `senderType` enum('employee','system') NOT NULL DEFAULT 'employee',
  `content` text NOT NULL,
  `contentOriginal` text DEFAULT NULL COMMENT 'Before edit, for audit',
  `clientMessageId` varchar(36) DEFAULT NULL COMMENT 'UUID for deduplication',
  `systemSourceType` varchar(50) DEFAULT NULL COMMENT 'For system messages: task, note, etc.',
  `systemSourceId` int(10) unsigned DEFAULT NULL COMMENT 'For system messages: ID of source entity',
  `isEdited` tinyint(1) NOT NULL DEFAULT 0,
  `isDeleted` tinyint(1) NOT NULL DEFAULT 0,
  `deletedAt` timestamp NULL DEFAULT NULL,
  `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  `editedAt` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_clientMessageId` (`clientMessageId`),
  KEY `idx_channelId_createdAt` (`channelId`, `createdAt`),
  KEY `idx_channelId_id` (`channelId`, `id`) COMMENT 'For pagination by message ID',
  KEY `idx_typeNum_senderEmployeeId` (`typeNum`, `senderEmployeeId`),
  CONSTRAINT `fk_message_channel` FOREIGN KEY (`channelId`) REFERENCES `staff_chat_channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Table: staff_chat_reactions** (Store Database)
```sql
CREATE TABLE `staff_chat_reactions` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `messageId` bigint(20) unsigned NOT NULL,
  `employeeId` int(10) unsigned NOT NULL,
  `emoji` varchar(20) NOT NULL COMMENT 'Unicode codepoint sequence',
  `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_message_employee_emoji` (`messageId`, `employeeId`, `emoji`),
  KEY `idx_messageId` (`messageId`),
  CONSTRAINT `fk_reaction_message` FOREIGN KEY (`messageId`) REFERENCES `staff_chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Table: staff_chat_read_receipts** (Store Database)
```sql
CREATE TABLE `staff_chat_read_receipts` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `channelId` int(10) unsigned NOT NULL,
  `employeeId` int(10) unsigned NOT NULL,
  `lastReadMessageId` bigint(20) unsigned NOT NULL,
  `readAt` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_channel_employee` (`channelId`, `employeeId`),
  KEY `idx_employeeId` (`employeeId`),
  CONSTRAINT `fk_read_channel` FOREIGN KEY (`channelId`) REFERENCES `staff_chat_channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Table: staff_chat_channel_members** (Store Database)
```sql
CREATE TABLE `staff_chat_channel_members` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `channelId` int(10) unsigned NOT NULL,
  `employeeId` int(10) unsigned NOT NULL,
  `membershipType` enum('auto','manual') NOT NULL DEFAULT 'auto',
  `isMuted` tinyint(1) NOT NULL DEFAULT 0,
  `joinedAt` timestamp NOT NULL DEFAULT current_timestamp(),
  `mutedAt` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_channel_employee` (`channelId`, `employeeId`),
  KEY `idx_employeeId` (`employeeId`),
  CONSTRAINT `fk_member_channel` FOREIGN KEY (`channelId`) REFERENCES `staff_chat_channels` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Table: staff_chat_attachments** (Store Database)
```sql
CREATE TABLE `staff_chat_attachments` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `messageId` bigint(20) unsigned NOT NULL,
  `fileName` varchar(255) NOT NULL,
  `originalName` varchar(255) NOT NULL,
  `mimeType` varchar(100) NOT NULL,
  `fileSize` int(10) unsigned NOT NULL COMMENT 'bytes',
  `filePath` varchar(500) NOT NULL,
  `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  PRIMARY KEY (`id`),
  KEY `idx_messageId` (`messageId`),
  CONSTRAINT `fk_attachment_message` FOREIGN KEY (`messageId`) REFERENCES `staff_chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Table: staff_chat_mentions** (Store Database)
```sql
CREATE TABLE `staff_chat_mentions` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `messageId` bigint(20) unsigned NOT NULL,
  `mentionedEmployeeId` int(10) unsigned NOT NULL,
  `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_message_employee` (`messageId`, `mentionedEmployeeId`),
  KEY `idx_mentionedEmployeeId` (`mentionedEmployeeId`),
  CONSTRAINT `fk_mention_message` FOREIGN KEY (`messageId`) REFERENCES `staff_chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Table: staff_chat_audit_log** (Store Database)
```sql
CREATE TABLE `staff_chat_audit_log` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `messageId` bigint(20) unsigned NOT NULL,
  `action` enum('create','edit','delete') NOT NULL,
  `employeeId` int(10) unsigned NOT NULL,
  `previousContent` text DEFAULT NULL,
  `newContent` text DEFAULT NULL,
  `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  PRIMARY KEY (`id`),
  KEY `idx_messageId` (`messageId`),
  KEY `idx_createdAt` (`createdAt`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Table: staff_chat_event_outbox** (Store Database - for async Ably publishing)
```sql
CREATE TABLE `staff_chat_event_outbox` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
  `eventType` varchar(50) NOT NULL COMMENT 'message.created, reaction.added, etc.',
  `channelId` int(10) unsigned NOT NULL,
  `payload` json NOT NULL,
  `status` enum('pending','processing','completed','failed') NOT NULL DEFAULT 'pending',
  `attempts` tinyint(3) unsigned NOT NULL DEFAULT 0,
  `lastAttemptAt` timestamp NULL DEFAULT NULL,
  `errorMessage` text DEFAULT NULL,
  `createdAt` timestamp NOT NULL DEFAULT current_timestamp(),
  `processedAt` timestamp NULL DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_status_createdAt` (`status`, `createdAt`),
  KEY `idx_channelId` (`channelId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

### Internal API Changes

#### Web API Endpoints (Session-based)

```yaml
# Channel Operations
GET /:typeNum/api/staff-chat/channels:
  description: "List channels user has access to"
  response:
    success: true
    channels: [{id, name, description, accessLevel, unreadCount, lastMessageAt}]

POST /:typeNum/api/staff-chat/channels:
  description: "Create new channel (manager+ only)"
  request:
    name: string (required, 1-100 chars)
    description: string (optional)
    accessLevel: enum (public|manager|owner)
  response:
    success: true
    channel: {id, name, ...}

GET /:typeNum/api/staff-chat/channels/:channelId:
  description: "Get channel details with members"
  response:
    success: true
    channel: {...}
    members: [{employeeId, employeeName, avatarUrl, isOnline}]

# Message Operations
GET /:typeNum/api/staff-chat/channels/:channelId/messages:
  description: "Get messages with pagination"
  query:
    before: messageId (optional, for pagination)
    limit: int (default 50, max 100)
  response:
    success: true
    messages: [{id, content, senderEmployeeId, senderName, senderAvatar, createdAt, editedAt, isEdited, reactions, attachments}]
    hasMore: boolean

POST /:typeNum/api/staff-chat/channels/:channelId/messages:
  description: "Send new message"
  request:
    content: string (required, 1-4000 chars)
    clientMessageId: uuid (optional, for deduplication)
    attachments: [file] (optional)
  response:
    success: true
    message: {...}

PATCH /:typeNum/api/staff-chat/messages/:messageId:
  description: "Edit message (within 24h, own messages only)"
  request:
    content: string (required, 1-4000 chars)
  response:
    success: true
    message: {...}

DELETE /:typeNum/api/staff-chat/messages/:messageId:
  description: "Delete message (own messages only)"
  response:
    success: true

# Reaction Operations
POST /:typeNum/api/staff-chat/messages/:messageId/reactions:
  description: "Add reaction to message"
  request:
    emoji: string (required, unicode emoji codepoint)
  response: 204 No Content
  error_cases:
    - 400: "Maximum 20 unique reactions per message"
    - 404: "Message not found"

DELETE /:typeNum/api/staff-chat/messages/:messageId/reactions:
  description: "Remove reaction (emoji in body to avoid URL encoding issues)"
  request:
    emoji: string (required, unicode emoji codepoint)
  response: 204 No Content

# Reactions JSON Schema (included in message responses)
# reactions: [
#   {
#     emoji: "👍",
#     count: 3,
#     users: [{employeeId: 1, employeeName: "John"}, ...]
#   },
#   ...
# ]

# Read Receipt Operations
POST /:typeNum/api/staff-chat/channels/:channelId/read:
  description: "Mark messages as read"
  request:
    lastReadMessageId: int (required)
  response:
    success: true

# Search Operations (Should Have)
GET /:typeNum/api/staff-chat/channels/:channelId/search:
  description: "Search messages in channel"
  query:
    q: string (required, min 2 chars)
    limit: int (default 20)
  response:
    success: true
    messages: [{...with context...}]

# Channel Settings
PATCH /:typeNum/api/staff-chat/channels/:channelId/settings:
  description: "Update channel settings (admin only)"
  request:
    retentionDays: int|null (optional)
  response:
    success: true

POST /:typeNum/api/staff-chat/channels/:channelId/mute:
  description: "Mute/unmute channel for current user"
  request:
    muted: boolean
  response: 204 No Content

# Channel Member Management (manager+ only)
GET /:typeNum/api/staff-chat/channels/:channelId/members:
  description: "List channel members with eligibility info"
  response:
    success: true
    members: [{employeeId, employeeName, avatarUrl, membershipType, isMuted, joinedAt}]
    eligibleToAdd: [{employeeId, employeeName, role}]

POST /:typeNum/api/staff-chat/channels/:channelId/members:
  description: "Add member to channel (manual override)"
  request:
    employeeId: int (required)
  response:
    success: true
    member: {...}

DELETE /:typeNum/api/staff-chat/channels/:channelId/members/:employeeId:
  description: "Remove member from channel (removes manual override)"
  response: 204 No Content

# Mentions Inbox
GET /:typeNum/api/staff-chat/mentions:
  description: "Get messages where current user was mentioned"
  query:
    limit: int (default 20, max 50)
    before: messageId (optional, for pagination)
  response:
    success: true
    mentions: [{messageId, channelId, channelName, content, senderName, senderAvatar, createdAt}]
    hasMore: boolean

# Attachment Download
GET /:typeNum/api/staff-chat/attachments/:attachmentId:
  description: "Get signed URL for attachment download"
  response:
    success: true
    downloadUrl: string (signed, expires in 15 minutes)
    fileName: string
    mimeType: string
    fileSize: int

# Ably Token (Web)
GET /:typeNum/api/staff-chat/ably-token:
  description: "Get Ably token for web real-time subscription"
  response:
    success: true
    token: string
    expiresAt: timestamp
    capabilities: {channelPattern: ["subscribe", "presence"]}
```

#### Mobile API Endpoints (JWT-based)

```yaml
# Same operations as web, different route prefix
Base: /api/mobile/staff-chat/:typeNum/

# Additional mobile-specific
POST /api/mobile/staff-chat/:typeNum/ably-token:
  description: "Get Ably token for real-time subscription"
  response:
    success: true
    token: string
    expiresAt: timestamp
```

### Application Data Models

```php
namespace BuyerKiosk\StaffChat\Models;

class Channel {
    public const ACCESS_PUBLIC = 'public';
    public const ACCESS_MANAGER = 'manager';
    public const ACCESS_OWNER = 'owner';

    private ?int $id = null;
    private string $typeNum;
    private string $name;
    private ?string $description;
    private string $accessLevel = self::ACCESS_PUBLIC;
    private bool $isDefault = false;
    private ?int $retentionDays = null;
    private ?int $createdByEmployeeId;
    private ?DateTime $createdAt;

    public static function fromRow(array $row): self;
    public function toArray(): array;
    public function canUserAccess(int $employeeId, int $roleLevel): bool;
}

class Message {
    public const SENDER_EMPLOYEE = 'employee';
    public const SENDER_SYSTEM = 'system';
    public const MAX_LENGTH = 4000;
    public const EDIT_WINDOW_HOURS = 24;

    private ?int $id = null;
    private int $channelId;
    private string $typeNum;
    private ?int $senderEmployeeId;
    private string $senderType = self::SENDER_EMPLOYEE;
    private string $content;
    private ?string $contentOriginal;
    private ?string $clientMessageId;
    private bool $isEdited = false;
    private bool $isDeleted = false;
    private ?DateTime $createdAt;
    private ?DateTime $editedAt;

    // Loaded relations
    private array $reactions = [];
    private array $attachments = [];
    private array $mentions = [];

    public static function fromRow(array $row): self;
    public function toArray(): array;
    public function canEdit(int $employeeId): bool;
    public function canDelete(int $employeeId): bool;
    public function isWithinEditWindow(): bool;
    public function parseMentions(): array;
}

class Reaction {
    private ?int $id = null;
    private int $messageId;
    private int $employeeId;
    private string $emoji;
    private ?DateTime $createdAt;

    public static function fromRow(array $row): self;
    public function toArray(): array;
}

class ReadReceipt {
    private ?int $id = null;
    private int $channelId;
    private int $employeeId;
    private int $lastReadMessageId;
    private ?DateTime $readAt;

    public static function fromRow(array $row): self;
    public function toArray(): array;
}
```

### Integration Points

```yaml
# Workbook Event Integration
Workbook_Task_Created:
  trigger: "Task created in workbook"
  action: "Create system message in public channel"
  data_flow:
    - Source: WorkbookPageController::createTask()
    - Target: StaffChatEventIntegration::onTaskCreated()
    - Payload: {taskId, title, assignedTo, dueDate}
    - Result: System message posted to public channel

Workbook_Note_Created:
  trigger: "Note posted in workbook"
  action: "Create system message in public channel"
  data_flow:
    - Source: WorkbookPageController::createNote()
    - Target: StaffChatEventIntegration::onNoteCreated()
    - Payload: {noteId, title, authorName}
    - Result: System message posted to public channel

# Ably Integration
Ably_Message_Events:
  channel: "chat:{typeNum}:{channelId}"
  events:
    - message.created: {messageId, channelId, content, sender, createdAt}
    - message.updated: {messageId, content, editedAt}
    - message.deleted: {messageId}
    - reaction.added: {messageId, emoji, employeeId}
    - reaction.removed: {messageId, emoji, employeeId}
    - read.updated: {channelId, employeeId, lastReadMessageId}
  presence_channel: "chat:{typeNum}:{channelId}:presence"
  presence_events:
    - typing.start: {employeeId, employeeName}
    - typing.stop: {employeeId}

# Firebase Push Notification Integration
FCM_Chat_Notifications:
  triggers:
    - New message in channel (if not muted, not actively viewing)
    - @mention in any message (always, unless globally disabled)
  payload:
    notification:
      title: "New message from {senderName}"
      body: "{messagePreview}"
    data:
      event: "staff_chat_message"
      typeNum: "{typeNum}"
      channelId: "{channelId}"
      messageId: "{messageId}"
      click_action: "FLUTTER_NOTIFICATION_CLICK"
```

---

## Runtime View

### Primary Flow: Send Message

```mermaid
sequenceDiagram
    actor User
    participant UI
    participant Controller as StaffChatApiController
    participant ChannelSvc as ChannelAccessService
    participant MsgSvc as MessageService
    participant MsgRepo as MessageRepository
    participant AblyPub as StaffChatAblyPublisher
    participant PushSvc as ChatPushNotificationService
    participant DB[(Store DB)]

    User->>UI: Compose & send message
    UI->>Controller: POST /channels/:id/messages

    Controller->>ChannelSvc: canAccessChannel(userId, channelId, 'write')
    ChannelSvc->>DB: Check membership & role
    ChannelSvc-->>Controller: AccessResult

    alt No Access
        Controller-->>UI: 403 Forbidden
    end

    Controller->>MsgSvc: createMessage(channelId, content, attachments)
    MsgSvc->>MsgSvc: validateContent(content)
    MsgSvc->>MsgSvc: parseMentions(content)
    MsgSvc->>MsgRepo: checkDuplicate(clientMessageId)

    alt Duplicate
        MsgRepo-->>MsgSvc: Existing message
        MsgSvc-->>Controller: Return existing (idempotent)
    end

    MsgSvc->>MsgRepo: insertMessage(...)
    MsgRepo->>DB: INSERT staff_chat_messages
    MsgRepo-->>MsgSvc: messageId

    par Save mentions
        MsgSvc->>MsgRepo: insertMentions(messageId, employeeIds)
    and Save attachments
        MsgSvc->>MsgRepo: insertAttachments(messageId, files)
    end

    MsgSvc->>AblyPub: publishMessageCreated(message)
    AblyPub-->>MsgSvc: Published

    MsgSvc->>PushSvc: notifyNewMessage(message, mentions)
    PushSvc-->>MsgSvc: Notifications queued

    MsgSvc-->>Controller: Message object
    Controller-->>UI: 201 {success: true, message: {...}}
```

### Error Handling

```yaml
Validation_Errors:
  empty_content:
    status: 400
    response: {error: "Message content is required", error_code: "validation_error"}
  content_too_long:
    status: 400
    response: {error: "Message exceeds 4000 characters", error_code: "validation_error"}
  invalid_emoji:
    status: 400
    response: {error: "Invalid emoji format", error_code: "validation_error"}

Authorization_Errors:
  not_authenticated:
    status: 401
    response: {error: "Authentication required", error_code: "unauthorized"}
  no_store_access:
    status: 403
    response: {error: "Access denied to this store", error_code: "store_access_denied"}
  no_channel_access:
    status: 403
    response: {error: "Access denied to this channel", error_code: "channel_access_denied"}
  not_message_owner:
    status: 403
    response: {error: "Cannot edit/delete other users' messages", error_code: "forbidden"}

Business_Rule_Errors:
  edit_window_expired:
    status: 422
    response: {error: "Messages can only be edited within 24 hours", error_code: "edit_window_expired"}
  reaction_limit_exceeded:
    status: 400
    response: {error: "Maximum 20 unique reactions per message", error_code: "reaction_limit"}
  rate_limit_exceeded:
    status: 429
    response: {error: "Too many messages, please wait", error_code: "rate_limited"}
    headers: {Retry-After: seconds}

Not_Found_Errors:
  channel_not_found:
    status: 404
    response: {error: "Channel not found", error_code: "not_found"}
  message_not_found:
    status: 404
    response: {error: "Message not found", error_code: "not_found"}

System_Errors:
  database_error:
    status: 500
    response: {error: "An error occurred", error_code: "server_error"}
    logging: "Full stack trace to error_log"
  ably_failure:
    handling: "Log error, don't fail request"
  fcm_failure:
    handling: "Log error, don't fail request"
```

---

## Deployment View

### Environment Configuration

```yaml
Environment_Variables:
  ABLY_KEY: "Ably API key (existing)"
  FIREBASE_CREDENTIALS_PATH: "Path to Firebase service account JSON (pending)"

File_Storage:
  path: "/var/www/buyerkiosk/uploads/chat/{typeNum}/{channelId}/"
  permissions: "755 for directories, 644 for files"
  cleanup: "Tied to message retention policy"

Database_Migrations:
  execution: "php userfrosting/conductor run"
  rollback: "Manual SQL scripts if needed"

Deployment_Checklist:
  - [ ] Run migrations on all store databases
  - [ ] Create default "Public" channel for each store
  - [ ] Verify Ably credentials
  - [ ] Configure Firebase credentials (when ready)
  - [ ] Update notification preferences schema
```

---

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: Controller pattern (ChatApiController)
  relevance: HIGH
  why: "Request handling, validation, response formatting"

- pattern: Entity model pattern (ChatMessage)
  relevance: HIGH
  why: "Data transformation, business methods"

- pattern: Ably publisher pattern (ChatAblyPublisher)
  relevance: HIGH
  why: "Real-time event publishing"

- pattern: Reaction pattern (NoteReaction)
  relevance: MEDIUM
  why: "Add/remove with UNIQUE constraint, INSERT IGNORE"

# New patterns created
- pattern: Channel access service
  relevance: HIGH
  why: "Complex role-based channel authorization logic"
```

### Security Patterns

```yaml
Authentication:
  web: "Session-based via $app->user"
  mobile: "JWT Bearer token via HybridAuthMiddleware"

Authorization:
  store_level: "checkStoreGroup() / StoreAccessMiddleware"
  channel_level: "ChannelAccessService.canAccessChannel()"
  message_level: "Message.canEdit() / Message.canDelete()"

Input_Validation:
  content: "1-4000 chars, no empty, strip dangerous HTML"
  emoji: "Valid Unicode emoji codepoints"
  file_upload: "JPEG/PNG/GIF only, max 10MB, validated MIME"

Rate_Limiting:
  messages: "30/minute/user/channel"
  uploads: "10/minute/user"
  reactions: "20/minute/user"
  implementation: "Redis counter with sliding window"
```

### Error Handling Pattern

```php
public function sendMessage(string $typeNum, int $channelId): void
{
    try {
        // 1. Validate authentication
        if (!$this->isAuthenticated()) {
            $this->jsonError(401, 'Authentication required', 'unauthorized');
            return;
        }

        // 2. Validate store access
        if (!$this->hasStoreAccess($typeNum)) {
            $this->jsonError(403, 'Access denied to this store', 'store_access_denied');
            return;
        }

        // 3. Validate channel access
        if (!$this->channelService->canAccessChannel($userId, $channelId, 'write')) {
            $this->jsonError(403, 'Access denied to this channel', 'channel_access_denied');
            return;
        }

        // 4. Validate input
        $data = json_decode($this->app->request->getBody(), true);
        $content = trim($data['content'] ?? '');

        if (empty($content)) {
            $this->jsonError(400, 'Message content is required', 'validation_error');
            return;
        }

        if (mb_strlen($content) > Message::MAX_LENGTH) {
            $this->jsonError(400, 'Message exceeds 4000 characters', 'validation_error');
            return;
        }

        // 5. Execute business logic
        $message = $this->messageService->createMessage($channelId, $content, ...);

        // 6. Publish events (graceful degradation)
        try {
            $this->ablyPublisher->publishMessageCreated($message);
        } catch (Exception $e) {
            error_log("Ably publish failed: " . $e->getMessage());
            // Don't fail the request
        }

        // 7. Return success
        $this->app->response->setStatus(201);
        $this->jsonResponse(['success' => true, 'message' => $message->toArray()]);

    } catch (Exception $e) {
        error_log("StaffChatApiController::sendMessage error: " . $e->getMessage());
        $this->jsonError(500, 'An error occurred', 'server_error');
    }
}
```

---

## Architecture Decisions

### ADR-1: Single Controller vs Separate Controllers

- [x] **Decision**: Use separate `StaffChatApiController` for web, `MobileStaffChatController` for mobile
- **Rationale**: Mobile needs different middleware chain (JWT vs session); separating allows cleaner code
- **Trade-offs**: Some code duplication in routing, but clearer responsibility
- **User confirmed**: ✅ 2026-01-01

### ADR-2: Per-Message vs Per-Channel Read Receipts

- [x] **Decision**: Per-channel "high water mark" (lastReadMessageId) instead of per-message tracking
- **Rationale**: Simpler storage, better performance, matches modern chat apps (Slack, Discord)
- **Trade-offs**: Less granular "who read which message" data
- **PRD Update Required**: PRD Feature 6 (Read Receipts) acceptance criteria need adjustment to match this decision
- **User confirmed**: ✅ 2026-01-01

### ADR-3: Channel Membership Storage

- [x] **Decision**: Hybrid approach - auto-sync based on role + manual overrides in `staff_chat_channel_members`
- **Rationale**: Matches PRD requirement; role changes auto-update access within 5 minutes; manual overrides persist for special cases
- **Trade-offs**: More complex access checking logic (role check + membership table check)
- **User confirmed**: ✅ 2026-01-01

### ADR-4: Message Retention Implementation

- [x] **Decision**: TaskEngine scheduled job to purge expired messages based on channel retention settings
- **Rationale**: Non-blocking, runs during low-traffic hours, coordinates attachment cleanup
- **Trade-offs**: Messages may exist slightly past retention period (up to 24h based on job schedule)
- **Implementation**: New job class `StaffChatRetentionJob` running daily at 3 AM
- **User confirmed**: ✅ 2026-01-01

### ADR-5: Ably Channel Structure

- [x] **Decision**: Use `chat:{typeNum}:{channelId}` for message events, `chat:{typeNum}:{channelId}:presence` for typing
- **Rationale**: Per-channel isolation reduces noise; matches Ably best practices; users only subscribe to channels they access
- **Trade-offs**: More channel subscriptions on frontend (negligible impact)
- **User confirmed**: ✅ 2026-01-01 (Recommended by Claude)

### ADR-6: Firebase Push vs Deferred Implementation

- [x] **Decision**: Full FCM implementation now - implement actual push notification sending
- **Rationale**: User wants push notifications at launch; requires Firebase service account setup
- **Prerequisites**: Firebase service account JSON credentials must be configured
- **Trade-offs**: Additional setup work required before launch
- **User confirmed**: ✅ 2026-01-01

### ADR-7: Ably Reliability Strategy

- [x] **Decision**: Async with transactional outbox pattern for Ably publishing
- **Rationale**: Decouples API response from Ably availability; provides guaranteed eventual delivery with retries
- **Implementation**:
  - Message insert + outbox entry in single DB transaction
  - Background worker (TaskEngine job) processes outbox every 5 seconds
  - Retries with exponential backoff (max 3 attempts)
  - Failed publishes logged with alert after exhausting retries
- **Trade-offs**: Slight delay in real-time delivery (up to 5s in normal case); more complex than fire-and-forget
- **User confirmed**: ✅ 2026-01-01 (Codex review)

---

## Quality Requirements

### Performance
- Message send: <200ms API response time
- Message list: <500ms for 50 messages with reactions
- Ably publish: <200ms after database commit
- Search: <1s for channel search results

### Reliability
- Message delivery: 99.9% (database + Ably)
- Graceful degradation: Ably/FCM failures logged, don't fail requests
- Idempotent message creation via clientMessageId

### Security
- All endpoints require authentication
- Store isolation enforced at all layers
- Channel access enforced per-request
- Audit log for message edits/deletes
- Attachment access via signed URLs (15-minute expiry)
- Ably tokens scoped to user's accessible channels only

### Analytics & Tracking

Events emitted to existing analytics pipeline (matches PRD tracking requirements):

| Event | Properties | When Emitted |
|-------|------------|--------------|
| `staff_chat_message_sent` | channelId, messageType (employee/system), hasAttachment, hasMention, characterCount | On message create |
| `staff_chat_message_read` | channelId, messageCount | On read receipt update |
| `staff_chat_reaction_added` | channelId, emoji | On reaction add |
| `staff_chat_channel_created` | channelId, accessLevel, creatorRole | On channel create |
| `staff_chat_channel_viewed` | channelId, messagesLoaded | On channel messages fetch |
| `staff_chat_push_opened` | channelId, notificationType | Via FCM analytics callback |
| `staff_chat_message_edited` | channelId, timeSinceSend | On message edit |
| `staff_chat_message_deleted` | channelId, timeSinceSend | On message delete |
| `staff_chat_image_uploaded` | channelId, fileSize, uploadTime | On attachment upload |
| `staff_chat_mention_sent` | channelId, mentionCount | On message with mentions |

**Implementation**: Events emitted via existing analytics service (same pattern as Workbook/ComebackCash).

---

## Risks and Technical Debt

### Known Technical Issues
- Firebase FCM requires service account JSON credentials (FIREBASE_CREDENTIALS_PATH env var)
- Existing SMS Chat uses `chat_*` table prefix; Staff Chat uses `staff_chat_*` for clear namespace separation

### Implementation Gotchas
- Workbook users authenticate differently (session token from kiosk)
- Multi-store users: each store has separate channel lists
- Message retention job needs coordination with attachment cleanup (delete files before DB records)
- Ably rate limits: 15 messages/second/channel (should be fine for light volume)
- Redis is required for rate limiting - verify availability in all environments
- Outbox worker runs every 5 seconds per store; consider impact on TaskEngine load
- Role-sync for channel membership: computed on-demand based on current employee role, not cached

---

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Send Message Happy Path**
```gherkin
Given: User is authenticated and has access to channel
And: Message content is valid (1-4000 chars)
When: User sends message via API
Then: Message is persisted to database
And: Ably event is published within 200ms
And: Response returns 201 with message object
And: Message appears in channel message list
```

**Scenario 2: Channel Access Denied**
```gherkin
Given: User is authenticated
And: Channel has accessLevel "manager"
And: User has role "employee" (not manager)
When: User attempts to view/post to channel
Then: Response returns 403 with error_code "channel_access_denied"
```

**Scenario 3: Edit Message Within Window**
```gherkin
Given: User sent a message 1 hour ago
When: User edits the message
Then: Message content is updated
And: isEdited flag is set to true
And: Audit log entry is created
And: Ably event is published
```

**Scenario 4: Edit Message Outside Window**
```gherkin
Given: User sent a message 25 hours ago
When: User attempts to edit the message
Then: Response returns 422 with error_code "edit_window_expired"
And: Message remains unchanged
```

**Scenario 5: Reaction Limit**
```gherkin
Given: Message already has 20 unique emoji reactions
When: User attempts to add 21st unique emoji
Then: Response returns 400 with error_code "reaction_limit"
```

### Test Coverage Requirements

- **Business Logic**: Channel access rules, message validation, edit window
- **Integration Points**: Ably publishing, database operations
- **Edge Cases**: Duplicate messages, deleted messages, role changes
- **Performance**: Message list pagination, search performance
- **Security**: Authentication, authorization, rate limiting

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Channel | A conversation thread within a store | Channels are store-scoped, role-based access |
| Public Channel | Default channel accessible to all store employees | Created automatically per store |
| Manager Channel | Channel requiring manager+ role for access | For private management discussions |
| Read Receipt | Record of which messages a user has seen | Tracked per-channel, not per-message |
| Mention | @reference to another user in a message | Triggers notification, rendered as link |
| System Message | Auto-generated message from workbook events | Distinct styling, cannot be edited/deleted |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| typeNum | Store identifier pattern [a-z][a-z]\d+ | e.g., "ou00", "pa42" |
| clientMessageId | UUID for message deduplication | Prevents duplicate sends on retry |
| Ably | Real-time messaging service | Used for instant message delivery |
| FCM | Firebase Cloud Messaging | Used for push notifications |
| roleLevel | User's role in a store (1=Owner, 2=Manager, 3=ShiftLead, 4=Employee) | Determines channel access |

---

*Document Status: ✅ Complete - All ADRs Confirmed (2026-01-01)*
