---
stepsCompleted: ['step-01-validate-prerequisites', 'step-02-design-epics', 'step-03-create-stories', 'step-04-final-validation']
inputDocuments:
  - '_bmad-output/planning-artifacts/prd.md'
  - '_bmad-output/planning-artifacts/architecture.md'
  - '_bmad-output/planning-artifacts/ux-design-specification.md'
workflowType: 'epics-and-stories'
project_name: "l'ami fiduciaire"
user_name: 'Saad'
date: '2026-03-11'
---

# l'ami fiduciaire - Epic Breakdown

## Overview

This document provides the complete epic and story breakdown for l'ami fiduciaire, decomposing the requirements from the PRD, UX Design, and Architecture into implementable stories.

## Requirements Inventory

### Functional Requirements

**Workspace & Onboarding (FR1-FR6):**
- FR1: Owner can create a new workspace with firm name and basic configuration
- FR2: Owner can invite team members to the workspace via email
- FR3: Owner can assign roles (Manager/Chef de Mission, Worker) to team members
- FR4: Owner can configure per-workspace permission toggles for Manager roles
- FR5: Owner can manage workspace settings (firm details, firm logo, display name)
- FR6: New users can sign up for a 14-day trial without credit card

**Team & Role Management (FR7-FR11):**
- FR7: Owner can view, add, and remove team members from the workspace
- FR8: Owner can change a team member's role within the workspace
- FR9: Manager can invite, remove, and change roles of team members when permission is granted by Owner
- FR10: System enforces role-based access -- Workers see only assigned items, Managers/Owners see all
- FR11: Owner can switch between multiple owned workspaces

**Client Management (FR12-FR15):**
- FR12: Owner/Manager can create, view, edit, and deactivate client records
- FR13: Owner/Manager can import clients in bulk
- FR14: Worker can view and interact with their assigned clients only
- FR15: System associates each client with a workspace and enforces tenant isolation

**Declaration Lifecycle (FR16-FR23):**
- FR16: Owner/Manager can create individual declarations for a client (type, deadline, assignment)
- FR17: Owner/Manager can bulk-create declarations across multiple clients with type and deadline selection
- FR18: Owner/Manager/Worker can update declaration status through its lifecycle
- FR19: Owner/Manager can reassign a declaration to a different team member
- FR20: Worker can edit declarations assigned to them (type correction, deadline adjustment)
- FR21: System auto-archives declarations when marked as closed
- FR22: Owner/Manager can re-open an archived declaration with audit trail
- FR23: Users can view archived declarations in read-only mode with full history

**Dashboard & Visibility (FR24-FR28):**
- FR24: Owner/Manager can view a command center dashboard showing all clients, declaration statuses, and priority alerts
- FR25: Worker can view a scoped dashboard showing only their assigned clients and declarations
- FR26: Dashboard surfaces priority alerts (overdue declarations, approaching deadlines, missing client documents)
- FR27: SaaS Admin can view a platform-level dashboard (workspace count, user count, storage, system health)
- FR28: SaaS Admin can view and respond to support tickets via an issue/support inbox

**Collaboration & Notifications (FR29-FR33):**
- FR29: Owner/Manager can nudge a team member on a specific declaration with one action
- FR30: Team members receive notifications with direct links to the relevant declaration
- FR31: Users can view a notification center showing all received nudges and system alerts
- FR32: Owner/Manager can schedule bulk notifications to clients for document requests
- FR33: System sends email notifications for key events (document requests, nudges, status changes)

**Client Portal & Document Exchange (FR34-FR40):**
- FR34: System generates unique token-based links for client interactions (no account required)
- FR35: External clients can upload documents via token link from any device including mobile
- FR36: External clients receive confirmation after successful document upload
- FR37: Team members can download client-uploaded documents from the declaration detail page
- FR38: Team members can send messages to clients within a declaration context
- FR39: External clients can view messages from their fiduciary firm via the portal
- FR40: Token links expire according to configurable security policies

**Search, Filtering & Navigation (FR41-FR44):**
- FR41: Users can filter declarations by status, client, assignee, type, and deadline range
- FR42: Filter selections persist across views within a session
- FR43: Users can perform quick search across clients and declarations
- FR44: Archive section is accessible as a top-level navigation item with its own filters and search

**Archive System (FR45-FR51):**
- FR45: System preserves full declaration history upon archiving (documents, messages, status changes, activity log)
- FR46: Users can browse archived declarations with hybrid filters and search
- FR47: Users can view an archive detail page as a read-only snapshot of the complete declaration
- FR48: Users can preview documents in-app from archived declarations
- FR49: Users can bulk-download archived declaration documents as ZIP
- FR50: System visually distinguishes archived declarations from active ones
- FR51: System enforces 10-year retention policy for archived data

**Activity & Audit (FR52-FR55):**
- FR52: System logs all data modifications with actor, timestamp, and change details
- FR53: Owner can view the full activity log for the workspace
- FR54: Manager can view activity logs when permission is granted
- FR55: Worker can view activity logs for their own actions only

**Platform Administration (FR56-FR58):**
- FR56: SaaS Admin can view all workspaces and their usage metrics
- FR57: SaaS Admin can manage platform-level configuration (global settings, feature flags, storage limits, email templates)
- FR58: System enforces subscription tier limits (team members, clients, storage, features)

### NonFunctional Requirements

**Performance (NFR1-NFR6):**
- NFR1: Page loads and common user actions complete within 2 seconds on standard Moroccan internet connections (ADSL/4G)
- NFR2: Bulk declaration creation (up to 50 declarations) completes within 10 seconds
- NFR3: Bulk notification scheduling (up to 50 clients) completes within 10 seconds
- NFR4: File uploads up to 10 MB complete within 60 seconds on 4G connections
- NFR5: Dashboard data renders within 3 seconds for workspaces with up to 200 active clients
- NFR6: Quick search returns results within 1 second

**Security (NFR7-NFR15):**
- NFR7: All data encrypted in transit (TLS 1.2+) and at rest (AES-256 for stored documents)
- NFR8: Multi-tenant data isolation enforced at every query -- no workspace can access another workspace's data
- NFR9: Authorization boundary violations return 404 (not 403) to prevent tenant existence leakage
- NFR10: Token-based client portal links are cryptographically secure, single-purpose, and expire after configurable duration
- NFR11: Two-factor authentication available for all firm users (TOTP-based)
- NFR12: All data modifications logged with actor, timestamp, and change details (audit trail)
- NFR13: CNDP (Law 09-08) compliant data handling -- EU-hosted infrastructure
- NFR14: Client financial documents accessible only to authorized workspace members and the specific client via their token link
- NFR15: Session management with secure cookie handling, CSRF protection, and session timeout after inactivity

**Scalability (NFR16-NFR20):**
- NFR16: System supports up to 200 concurrent workspaces while maintaining performance targets
- NFR17: System supports up to 1,000 total users across all workspaces
- NFR18: Individual workspace supports up to 500 active clients and 5,000 active declarations
- NFR19: File storage architecture supports growth to 1 TB total platform storage
- NFR20: System handles seasonal peak loads (bilan season Jan-Mar: 2-3x normal usage)

**Reliability & Data Protection (NFR21-NFR28):**
- NFR21: Platform targets 99.5% uptime (max ~3.6 hours unplanned downtime per month)
- NFR22: Automated hourly database backups with 1-hour RPO
- NFR23: Database binary/transaction logging enabled for point-in-time recovery
- NFR24: Backup restore procedure documented and tested -- full restore within 1 hour (RTO)
- NFR25: Backup retention: daily backups for 30 days, weekly for 6 months
- NFR26: Email notification delivery >99% success rate with retry logic (up to 3 retries within 5 minutes)
- NFR27: Monitoring and alerting for system health, error rates, and resource utilization
- NFR28: Zero data loss tolerance for completed transactions

### Additional Requirements

**From Architecture Document:**

- **Pre-Phase Requirement:** "Folders → Declarations" terminology migration must complete before any Phase 1-5 work begins (~40 files affected: models, controllers, enums, pages, tests, migrations)
- **Redis Integration (D4/D5/D7):** Add Redis service for cache, queue, and sessions -- foundational service required before queue-dependent features
- **Permission Toggle Storage (D1):** JSON `permissions` column on `workspace_user` pivot table with default permissions per role defined in config
- **Archive Strategy (D2):** Status-based archiving with `archived_at` timestamp column on declarations table, `scopeActive()` and `scopeArchived()` Eloquent scopes
- **Search Implementation (D3):** MySQL FULLTEXT indexes on searchable columns, `MATCH...AGAINST` in natural language mode
- **In-App Notifications (D8):** Laravel `DatabaseNotification` system with database + mail channels, workspace_id in all notification payloads
- **Bulk Operation Processing (D9):** Synchronous DB operations wrapped in `DB::transaction()`, individual email sends queued via Redis
- **Filter Persistence (D11):** URL query parameters via Inertia `router.get()`, controllers read from Request and apply Eloquent scopes
- **Document Preview (D12):** Client-side PDF.js for PDFs, native `<img>` for images, shadcn-vue Dialog wrapper
- **Real-Time Features (D10):** Deferred for MVP -- no WebSocket wiring. Soketi stays configured for post-MVP
- **Email Service (D13):** Amazon SES (eu-west-1 Ireland) with SPF, DKIM, DMARC configuration
- **Hosting (D14):** EU-based VPS (Hetzner/DigitalOcean) managed via Laravel Forge with Nginx, PHP-FPM, MySQL, Redis, Supervisor
- **File Storage (D15):** S3-compatible object storage with AES-256 SSE, Spatie Media Library S3 disk driver
- **Monitoring (D16):** Laravel Pulse (application monitoring) + Sentry (error tracking/alerting)
- **Declaration Status Flow:** Created → En cours → En attente client → En cours → Termine → Ferme → [auto-archive]. Invalid transitions must be enforced
- **Workspace Scoping Pattern:** Every model query MUST be workspace-scoped. Use `abort(404)` for all authorization failures (never 403)
- **Role-Scoped Queries:** Workers see only their assigned items via `scopeForUser()` on Declaration model
- **Dashboard Cache:** Redis `Cache::remember()` with 5-minute TTL, invalidated on declaration status change/creation/deletion
- **Controller Traits:** `HasWorkspaceScope` and `AuthorizesPermissions` for shared patterns across controllers
- **Implementation Sequence:** Redis → Permissions → Archive column → FULLTEXT → Notifications → S3 → SES → Hosting → Monitoring

**From UX Design Specification:**

- **Role-Driven Sidebar:** Different nav items based on role. Owner/Manager: Dashboard, Clients, Declarations, Archive, Team, Settings. Worker: Dashboard, My Declarations, Archive, Settings
- **Desktop-First Firm App, Mobile-First Client Portal:** Two distinct design targets within the same application
- **Dense Table Design:** 13px body text in tables, compact padding (py-2 px-3), showing 25-50 rows without scrolling
- **Summary Cards (StatCard):** KPI cards above tables that act as clickable filters (e.g., "3 En retard" filters table to overdue)
- **Inline Row Actions:** Status changes, nudge, reassign available directly on table rows via dropdown menu
- **Persistent FilterBar:** Always visible above data tables with instant filter application via URL params
- **BulkActionBar:** Floating toolbar appears on multi-select with available bulk operations
- **StatusBadge:** Consistent color-coded status pills with icon + text (never color alone) across all views
- **Deadline Proximity Indicators:** Green (>7d) → Amber (3-7d) → Red (<3d) → Pulsing red (overdue)
- **Toast Notifications (Sonner):** Non-blocking feedback for quick actions, positioned top-right, max 3 visible
- **Confirmation Dialogs:** Required for delete, archive, bulk actions, reassignment; NOT required for status updates and nudges
- **EmptyState Component:** Every list/table has a designed empty state with icon, text, and action button
- **DeclarationStatusStepper:** Visual progress indicator in declaration detail showing lifecycle position
- **FileUploadZone:** Drag-and-drop + click-to-browse with progress bar, camera-friendly for mobile
- **DataTable with TanStack Table v8:** Sortable headers, row selection, sticky header, pagination (25/50/100)
- **Responsive Breakpoints:** Tables → card list on mobile (<768px), sidebar → overlay drawer, KPI cards stack single-column
- **WCAG 2.1 AA Compliance:** Color contrast, keyboard navigation, screen reader support, 44px min touch targets
- **shadcn-vue Components to Install:** table, combobox, calendar, popover, toast/sonner, progress, switch
- **Date Format:** DD/MM/YYYY with relative countdown for deadlines ("dans 5 jours")
- **French-Native UI:** All labels, copy, and professional terminology in Moroccan French

### FR Coverage Map

**Epic 0: Foundation Migration & Infrastructure Setup**
- FR16: Epic 0 - Declaration creation (renamed from folders)
- FR18: Epic 0 - Declaration status updates (renamed from folders)
- FR19: Epic 0 - Declaration reassignment (renamed from folders)
- FR20: Epic 0 - Worker declaration editing (renamed from folders)

**Epic 1: Team Management & Permission System**
- FR3: Epic 1 - Owner assigns roles to team members
- FR4: Epic 1 - Owner configures permission toggles for Managers
- FR7: Epic 1 - Owner views, adds, removes team members
- FR8: Epic 1 - Owner changes team member roles
- FR9: Epic 1 - Manager manages team when permitted
- FR10: Epic 1 - System enforces role-based access
- FR11: Epic 1 - Owner switches between workspaces

**Epic 2: Role-Driven Dashboard & Command Center**
- FR24: Epic 2 - Owner/Manager command center dashboard
- FR25: Epic 2 - Worker scoped dashboard
- FR26: Epic 2 - Priority alerts on dashboard

**Epic 3: Collaboration, Nudge System & Notifications**
- FR29: Epic 3 - One-click nudge on declarations
- FR30: Epic 3 - Notifications with direct links
- FR31: Epic 3 - Notification center for all users
- FR32: Epic 3 - Bulk notification scheduling for clients
- FR33: Epic 3 - Email notifications for key events

**Epic 4: Bulk Operations, Search & Advanced Filtering**
- FR17: Epic 4 - Bulk declaration creation
- FR41: Epic 4 - Filter declarations by status, client, assignee, type, deadline
- FR42: Epic 4 - Filter persistence across views
- FR43: Epic 4 - Quick search across clients and declarations
- FR44: Epic 4 - Archive as top-level nav with own filters/search

**Epic 5: Archive System & Document Preview**
- FR21: Epic 5 - Auto-archive on close
- FR22: Epic 5 - Re-open archived declaration with audit trail
- FR23: Epic 5 - View archived declarations read-only
- FR45: Epic 5 - Full history preservation on archive
- FR46: Epic 5 - Browse archived declarations with filters/search
- FR47: Epic 5 - Archive detail page (read-only snapshot)
- FR48: Epic 5 - In-app document preview
- FR49: Epic 5 - Bulk ZIP download of archived documents
- FR50: Epic 5 - Visual distinction for archived declarations
- FR51: Epic 5 - 10-year retention policy enforcement

**Epic 6: Platform Administration & Subscription Enforcement**
- FR27: Epic 6 - SaaS admin platform dashboard
- FR28: Epic 6 - Support ticket inbox
- FR56: Epic 6 - View all workspaces and metrics
- FR57: Epic 6 - Platform-level configuration management
- FR58: Epic 6 - Subscription tier limit enforcement

**Epic 7: Production Infrastructure & Deployment**
- NFR-driven: NFR7 (encryption), NFR13 (CNDP/EU hosting), NFR14 (document access control), NFR19 (1TB storage), NFR21-NFR28 (uptime, backups, email delivery, monitoring)

**Already Built (Existing -- Enhanced by Epics Above):**
- FR1: Existing - Workspace creation
- FR2: Existing - Team invitations (enhanced by Epic 1)
- FR5: Existing - Workspace settings
- FR6: Existing - Trial signup
- FR12: Existing - Client CRUD (enhanced by Epic 1 role scoping, Epic 4 filtering)
- FR13: Existing - Bulk client import
- FR14: Existing - Worker assigned clients (enhanced by Epic 1 role enforcement)
- FR15: Existing - Client workspace isolation
- FR34: Existing - Token-based portal links
- FR35: Existing - Client document upload via portal
- FR36: Existing - Upload confirmation
- FR37: Existing - Team document download
- FR38: Existing - In-declaration messaging
- FR39: Existing - Client message viewing
- FR40: Existing - Token expiry policies
- FR52: Existing - Activity logging (Spatie Activity Log)
- FR53: Existing - Owner activity log viewing (enhanced by Epic 1 permissions)
- FR54: Existing - Manager activity log viewing (enhanced by Epic 1 permissions)
- FR55: Existing - Worker own-action log viewing (enhanced by Epic 1 permissions)

**Coverage:** All 58 FRs mapped. 0 FRs missing.

## Epic List

### Epic 0: Foundation Migration & Infrastructure Setup
The platform uses correct professional terminology ("declarations" not "folders") and the technical foundation (Redis for cache/queue/sessions, base database migrations) is ready for all new features. This pre-phase must complete before any feature development begins.
**FRs covered:** Foundation for FR16, FR18, FR19, FR20 (terminology migration)
**NFRs addressed:** NFR8 (workspace scoping patterns), NFR9 (abort(404) convention)

### Epic 1: Team Management & Permission System
Firm owners can manage their team, assign roles (Owner/Manager/Worker), and configure per-workspace permission toggles for Managers. The system enforces role-based access across all views -- Workers see only assigned items, Managers/Owners see all. Workspace switching works for multi-workspace Owners.
**FRs covered:** FR3, FR4, FR7, FR8, FR9, FR10, FR11
**NFRs addressed:** NFR8 (tenant isolation), NFR9 (404 for auth violations), NFR12 (audit trail on role changes)

### Epic 2: Role-Driven Dashboard & Command Center
Owners/Managers open the app and see their entire firm's status on one screen -- KPI summary cards, priority alerts, declaration status overview, and activity feed. Workers see a scoped dashboard showing only their assigned workload. The "morning command center" moment that drives trial-to-paid conversion.
**FRs covered:** FR24, FR25, FR26
**NFRs addressed:** NFR1 (2s page loads), NFR5 (3s dashboard render for 200 clients)

### Epic 3: Collaboration, Nudge System & Notifications
Owners/Managers can nudge team members on specific declarations with one click. All users have a notification center with direct links to relevant declarations. The system sends reliable email notifications for key events. Bulk notification scheduling enables document request campaigns to multiple clients.
**FRs covered:** FR29, FR30, FR31, FR32, FR33
**NFRs addressed:** NFR3 (10s bulk notification), NFR26 (>99% email delivery)

### Epic 4: Bulk Operations, Search & Advanced Filtering
Power users can create 50 declarations in one action with type and deadline selection across multiple clients. A persistent filter bar with status, client, assignee, type, and date range filters appears on all list views. Quick search across clients and declarations returns results in under 1 second. Archive has its own top-level navigation with dedicated filters.
**FRs covered:** FR17, FR41, FR42, FR43, FR44
**NFRs addressed:** NFR2 (10s bulk creation), NFR6 (1s search)

### Epic 5: Archive System & Document Preview
Completed declarations are automatically archived when closed, preserving full history (documents, messages, status changes, activity log). Users can browse, filter, and search the archive. Archive detail pages show read-only snapshots with in-app document preview (PDF.js). Owners can re-open archived items with audit trail. Bulk ZIP download and 10-year retention policy ensure compliance.
**FRs covered:** FR21, FR22, FR23, FR45, FR46, FR47, FR48, FR49, FR50, FR51
**NFRs addressed:** NFR1 (page load performance), NFR12 (audit trail for re-open)

### Epic 6: Platform Administration & Subscription Enforcement
SaaS admin can monitor platform health via a dedicated dashboard (workspace count, user count, storage, system health). Admin can view and respond to support tickets, manage platform configuration, and enforce subscription tier limits across all workspaces.
**FRs covered:** FR27, FR28, FR56, FR57, FR58
**NFRs addressed:** NFR16-NFR20 (scalability targets)

### Epic 7: Production Infrastructure & Deployment
The platform runs reliably in production with EU-hosted infrastructure (CNDP compliance), S3-compatible encrypted file storage, Amazon SES email delivery (eu-west-1), Laravel Forge deployment, Pulse + Sentry monitoring, and automated backup procedures. All reliability and security NFRs are met for launch.
**FRs covered:** NFR-driven epic
**NFRs addressed:** NFR7, NFR13, NFR14, NFR19, NFR21, NFR22, NFR23, NFR24, NFR25, NFR26, NFR27, NFR28

---

## Epic 0: Foundation Migration & Infrastructure Setup

The platform uses correct professional terminology ("declarations" not "folders") and the technical foundation (Redis for cache/queue/sessions, base database migrations) is ready for all new features. This pre-phase must complete before any feature development begins.

### Story 0.1: Rename Folders to Declarations in Database

As a developer,
I want the database tables and columns to use "declaration" terminology instead of "folder",
So that the data layer reflects the correct professional terminology before building new features.

**Acceptance Criteria:**

**Given** the existing `folders` table exists with data
**When** the migration runs
**Then** the `folders` table is renamed to `declarations`
**And** all `folder_id` foreign key columns across related tables are renamed to `declaration_id`
**And** the `folder_invitations` table is renamed to `declaration_invitations`
**And** all indexes referencing "folder" are renamed to reference "declaration"
**And** the migration is reversible (rollback renames back to "folder")
**And** existing data is preserved with zero data loss

### Story 0.2: Rename Folders to Declarations in Backend

As a developer,
I want all backend PHP code to use "Declaration" terminology instead of "Folder",
So that the codebase is consistent with the database and professional domain language.

**Acceptance Criteria:**

**Given** the database migration from Story 0.1 has been applied
**When** the backend rename is complete
**Then** the `Folder` model is renamed to `Declaration` with updated table name, relationships, and fillable attributes
**And** the `FolderInvitation` model is renamed to `DeclarationInvitation`
**And** `FolderController` is renamed to `DeclarationController` with updated route model binding
**And** `FolderMediaController` is renamed to `DeclarationMediaController`
**And** all Form Request classes (`StoreFolderRequest`, `UpdateFolderRequest`) are renamed with "Declaration" prefix
**And** all Enums (`FolderStatus`, `FolderType`, `FolderPriority`) are renamed to `DeclarationStatus`, `DeclarationType`, `DeclarationPriority`
**And** all Mail classes (`FolderConfirmationMail`, `FolderFileRequestMail`, `FolderInviteMail`, `FolderSituationMail`, `FolderTextMessageMail`) are renamed with "Declaration" prefix
**And** the `ValidateFolderInvitation` middleware is renamed to `ValidateClientPortalToken`
**And** all routes in `web.php` are updated from "folders" to "declarations"
**And** all existing feature tests are updated and passing
**And** `FolderFactory` is renamed to `DeclarationFactory`

### Story 0.3: Rename Folders to Declarations in Frontend

As a developer,
I want all frontend Vue/TypeScript code to use "declaration" terminology instead of "folder",
So that the UI codebase is consistent with the backend and domain language.

**Acceptance Criteria:**

**Given** the backend rename from Story 0.2 is complete
**When** the frontend rename is complete
**Then** `resources/js/pages/folders/` directory is renamed to `resources/js/pages/declarations/`
**And** all Vue page components (Index, Show, Create, Edit) reference "declaration" in component names and content
**And** all folder-related components in `resources/js/components/` are renamed with "Declaration" prefix
**And** TypeScript types referencing "Folder" are renamed to "Declaration" in `resources/js/types/`
**And** all Wayfinder route references are updated from folder routes to declaration routes
**And** all user-facing labels and text display "Declaration" / "Declarations" (French: "Declaration" / "Declarations")
**And** the client portal pages reference "declaration" instead of "folder" in all user-facing copy
**And** the application compiles without TypeScript errors
**And** all pages render correctly with no broken links or references

### Story 0.4: Configure Redis for Cache, Queue & Sessions

As a platform operator,
I want Redis configured as the driver for caching, job queues, and session storage,
So that the application has the infrastructure foundation for queued email delivery, dashboard caching, and reliable session management.

**Acceptance Criteria:**

**Given** the Docker/Sail development environment is running
**When** Redis is configured
**Then** a Redis service is added to `compose.yaml` (or confirmed already present via Sail)
**And** `CACHE_STORE=redis` is set in the environment configuration
**And** `QUEUE_CONNECTION=redis` is set in the environment configuration
**And** `SESSION_DRIVER=redis` is set in the environment configuration
**And** the `queue:work` process is running and processes test jobs successfully
**And** `Cache::put()` and `Cache::get()` operations work correctly
**And** user sessions persist correctly across page navigations
**And** the `composer dev` command starts the queue worker alongside the existing services
**And** a `failed_jobs` table migration exists for monitoring failed queue jobs

### Story 0.5: Add Foundation Database Migrations and Declaration Status Flow

As a developer,
I want the base database migrations and declaration status enforcement in place,
So that future epics can build on a solid data foundation without needing to alter the schema themselves.

**Acceptance Criteria:**

**Given** the declarations table exists (from Story 0.1)
**When** the foundation migrations and observer are in place
**Then** a `permissions` JSON column is added to the `workspace_user` pivot table (nullable, default null)
**And** the `WorkspaceUser` model casts `permissions` to array
**And** an `archived_at` nullable timestamp column is added to the `declarations` table
**And** the `Declaration` model has `scopeActive()` (whereNull archived_at) and `scopeArchived()` (whereNotNull archived_at) Eloquent scopes
**And** the `DeclarationStatus` enum includes all lifecycle values: `created`, `en_cours`, `en_attente_client`, `termine`, `ferme`
**And** a `DeclarationObserver` is registered that enforces valid status transitions per the Architecture status flow
**And** the observer auto-sets `archived_at = now()` when status becomes `ferme`
**And** invalid status transitions throw a validation error (e.g., `created` cannot jump to `ferme`)
**And** all existing tests pass with the new migrations applied

---

**Epic 0 Summary:** 5 stories covering terminology migration (database, backend, frontend), Redis infrastructure, and foundation migrations. All FRs for Epic 0 are covered.

## Epic 1: Team Management & Permission System

Firm owners can manage their team, assign roles (Owner/Manager/Worker), and configure per-workspace permission toggles for Managers. The system enforces role-based access across all views -- Workers see only assigned items, Managers/Owners see all. Workspace switching works for multi-workspace Owners.

### Story 1.1: Permission Configuration & Controller Traits

As a developer,
I want a centralized permission configuration and reusable controller traits for workspace scoping and permission checking,
So that all future controllers can enforce role-based access consistently with minimal code duplication.

**Acceptance Criteria:**

**Given** the `permissions` JSON column exists on `workspace_user` (from Story 0.5)
**When** the permission system is configured
**Then** a `config/permissions.php` file defines default permissions per role: Owner gets all (`['*']`), Manager gets configurable defaults (`can_manage_team: false`, `can_view_activity_logs: true`, `can_configure_portal: false`), Worker gets none
**And** a `Permission` enum exists in `app/Enums/Permission.php` with all permission keys as snake_case values
**And** a `HasWorkspaceScope` trait exists in `app/Concerns/` that provides `currentWorkspace()` and `authorizeWorkspaceAccess()` helper methods using session-based `current_workspace_id`
**And** an `AuthorizesPermissions` trait exists in `app/Concerns/` that provides `authorizePermission(string $permission)` method: Owners always pass, Workers always fail (abort 404), Managers check JSON permissions column
**And** authorization failures return `abort(404)` (never 403) per Architecture convention
**And** unit tests verify permission checking logic for all three roles
**And** unit tests verify that unknown permission keys default to `false`

### Story 1.2: Team Management Page -- View & Invite Members

As a firm owner,
I want to view all team members in my workspace and invite new members via email,
So that I can build my team and see who has access to my firm's data.

**Acceptance Criteria:**

**Given** the owner is logged in and viewing the Team page
**When** they navigate to `/team`
**Then** a team index page displays all workspace members in a table showing: name, email, role, join date, and status (active/pending)
**And** pending invitations are shown with "Pending" status badge
**And** an "Invite Member" button opens a form with email input and role selection dropdown (Manager or Worker)
**And** submitting the invite form sends an invitation email to the specified address
**And** the invitation appears in the team list as "Pending" immediately
**And** the page uses the `HasWorkspaceScope` trait to scope members to the current workspace
**And** Workers cannot access the Team page (abort 404)
**And** Managers can view the team list but only see the "Invite Member" button if they have `can_manage_team` permission
**And** the page follows the existing AppLayout with breadcrumbs: Dashboard / Team
**And** an EmptyState is shown when the workspace has only the owner ("Invite your first team member")

### Story 1.3: Role Assignment & Member Removal

As a firm owner,
I want to change a team member's role or remove them from the workspace,
So that I can adjust team structure as my firm's needs evolve.

**Acceptance Criteria:**

**Given** the owner is viewing the Team management page
**When** they click the action menu on a team member row
**Then** a dropdown menu shows "Change Role" and "Remove from Workspace" options
**And** "Change Role" opens a popover with role selection (Manager/Worker) and a confirm button
**And** changing a role updates the `WorkspaceUser` role and resets permissions to role defaults
**And** "Remove from Workspace" shows a confirmation dialog with the member's name and a destructive "Remove" button
**And** removing a member detaches them from the workspace (does not delete their user account)
**And** the owner cannot change their own role or remove themselves
**And** a Manager with `can_manage_team` permission can also change roles and remove members (except the Owner)
**And** all role changes and removals are logged via Spatie Activity Log with actor, target user, old role, and new role
**And** a success toast confirms each action ("Role updated" / "Member removed")
**And** the team list updates immediately after each action

### Story 1.4: Manager Permission Toggle Matrix

As a firm owner,
I want to configure which specific permissions each Manager has in my workspace,
So that I can grant or restrict Manager capabilities based on my trust level and firm structure.

**Acceptance Criteria:**

**Given** the owner is viewing the Team management page
**When** they click "Manage Permissions" on a Manager team member
**Then** a permissions page (or slide-over panel) displays all configurable permission toggles with descriptive labels
**And** each toggle shows the permission name in French (e.g., "Gerer l'equipe", "Voir les journaux d'activite", "Configurer le portail client")
**And** toggles reflect the Manager's current permission state (from JSON column)
**And** toggling a permission immediately saves via PUT request to `/team/{workspaceUser}/permissions`
**And** a success toast confirms "Permissions updated"
**And** the permission toggles are only visible for Manager role members (not Workers or Owners)
**And** only Owners can access the permissions management (Managers with `can_manage_team` cannot modify other Managers' permissions)
**And** permission changes are logged via Spatie Activity Log
**And** if a Manager's `can_manage_team` is toggled off, the "Invite Member" button disappears from their Team page view on next load

### Story 1.5: Role-Based Access Enforcement Across Views

As a firm worker,
I want to see only my assigned clients and declarations when I navigate the platform,
So that I can focus on my work without being overwhelmed by the entire firm's data.

**Acceptance Criteria:**

**Given** a Worker is logged in to the workspace
**When** they navigate to the Clients page
**Then** they see only clients that have at least one declaration assigned to them
**And** the client count reflects their scoped view (not the total workspace count)

**Given** a Worker is logged in to the workspace
**When** they navigate to the Declarations page
**Then** they see only declarations where `assigned_to` equals their user ID
**And** the `Declaration` model's `scopeForUser()` method is applied: Workers get `where('assigned_to', $user->id)`, Owners/Managers get all workspace declarations

**Given** an Owner or Manager is logged in
**When** they navigate to any page (Clients, Declarations)
**Then** they see all items in the workspace with no scoping restrictions

**Given** a Worker tries to access a declaration not assigned to them via direct URL
**When** the controller loads the declaration
**Then** the system returns 404 (not 403)

**And** the `DeclarationController`, `ClientController`, and all existing controllers apply `HasWorkspaceScope` and role scoping consistently
**And** activity log viewing is scoped: Owners see all, Managers see all if `can_view_activity_logs` is true (else 404), Workers see only their own actions
**And** the sidebar navigation adapts per role: Owner/Manager sees "Dashboard, Clients, Declarations, Archive, Team, Settings"; Worker sees "Dashboard, My Declarations, Archive, Settings"

### Story 1.6: Workspace Switching for Multi-Workspace Owners

As a firm owner with multiple workspaces,
I want to switch between my workspaces from the sidebar,
So that I can manage multiple cabinets without logging out and back in.

**Acceptance Criteria:**

**Given** an Owner has multiple workspaces
**When** they click the workspace switcher in the sidebar
**Then** a dropdown displays all workspaces they own with the current one highlighted
**And** selecting a different workspace updates the session `current_workspace_id` and redirects to the dashboard
**And** all data (team, clients, declarations) now reflects the selected workspace

**Given** a Manager or Worker belongs to a single workspace
**When** they view the sidebar
**Then** the workspace name is displayed but the switcher dropdown is not available (or shows only one entry)

**And** the existing `WorkspaceSwitchController` is enhanced if needed to support the improved UX
**And** workspace switching is logged in the activity log

---

**Epic 1 Summary:** 6 stories covering permission configuration, team management (view, invite, role change, remove), Manager permission toggles, role-based access enforcement across all views, and workspace switching. All FRs for Epic 1 (FR3, FR4, FR7, FR8, FR9, FR10, FR11) are covered.

## Epic 2: Role-Driven Dashboard & Command Center

Owners/Managers open the app and see their entire firm's status on one screen -- KPI summary cards, priority alerts, declaration status overview, and activity feed. Workers see a scoped dashboard showing only their assigned workload. The "morning command center" moment that drives trial-to-paid conversion.

### Story 2.1: Owner/Manager Command Center Dashboard

As a firm owner or manager,
I want to see my entire firm's operational status on one screen when I open the app,
So that I can instantly identify what needs my attention without drilling into individual clients or declarations.

**Acceptance Criteria:**

**Given** an Owner or Manager is logged in
**When** they navigate to the Dashboard (`/dashboard`)
**Then** the page displays a row of KPI summary cards (StatCard components) in a 4-column CSS Grid layout:
- "En retard" (overdue) -- red, count of declarations past deadline
- "Cette semaine" (due this week) -- amber, count of declarations due within 7 days
- "En attente client" (waiting for client) -- blue, count of declarations with `en_attente_client` status
- "En cours" (on track) -- green, count of declarations with `en_cours` status

**And** each StatCard is clickable and navigates to the Declarations list page with the corresponding filter pre-applied via URL query params (e.g., `/declarations?status=en_attente_client`)
**And** a declarations summary table appears below the KPI cards showing the most urgent declarations (sorted by deadline ascending, limited to 10-15 rows) with columns: Client, Type, Deadline (with proximity color), Assignee, Status badge
**And** each table row is clickable and navigates to the declaration detail page
**And** inline row actions are available via a dropdown menu (View, Nudge, Reassign)
**And** dashboard data is served from Redis cache (`Cache::remember()` with 5-minute TTL, key: `dashboard:{workspace_id}:{user_id}`)
**And** the `DashboardController` is rewritten to aggregate declaration counts by status, overdue counts, and due-this-week counts using role-scoped queries
**And** the page renders within 3 seconds for workspaces with up to 200 active clients (NFR5)
**And** the page uses the AppLayout with role-driven sidebar navigation

### Story 2.2: Priority Alerts Panel

As a firm owner or manager,
I want to see a prioritized list of alerts for items requiring immediate attention,
So that I can act on the most urgent issues before they become missed deadlines.

**Acceptance Criteria:**

**Given** the Owner/Manager dashboard is loaded
**When** the priority alerts panel renders (below or alongside the KPI cards)
**Then** alerts are displayed as a list sorted by urgency with the following categories:
- **Critical (red):** Overdue declarations (past deadline) -- shows client name, declaration type, days overdue
- **Warning (amber):** Approaching deadlines (due within 3 days) -- shows client name, type, days remaining
- **Info (blue):** Missing client documents (status `en_attente_client` for >3 days) -- shows client name, days waiting

**And** each alert includes a direct link to the relevant declaration detail page
**And** the alert count is capped at 20 most urgent items with a "View all" link to the filtered declarations list
**And** deadline proximity uses the color gradient: green (>7d) → amber (3-7d) → red (<3d) → pulsing red (overdue)
**And** alerts that have been acted on (status changed, declaration reassigned) disappear on next dashboard refresh
**And** if there are no alerts, an encouraging message is shown: "Aucune alerte -- tout est en ordre"
**And** alert data is included in the same cached dashboard query (no separate API call)

### Story 2.3: Worker Scoped Dashboard

As a firm worker,
I want to see only my assigned declarations and their statuses when I open the app,
So that I can quickly identify what I need to work on today without information overload.

**Acceptance Criteria:**

**Given** a Worker is logged in
**When** they navigate to the Dashboard
**Then** the page displays the same KPI card layout as the Owner dashboard but counts reflect only declarations assigned to the Worker
**And** the summary table shows only the Worker's assigned declarations sorted by deadline ascending
**And** the priority alerts panel shows only alerts for the Worker's assigned declarations
**And** StatCards are clickable and navigate to `/declarations?assignee=me&status={status}`
**And** the page title or subtitle indicates the scoped view (e.g., "Mes declarations" or the Worker's name)

**Given** a Worker has no assigned declarations
**When** they view the Dashboard
**Then** an EmptyState is displayed: "Aucune declaration assignee -- contactez votre responsable"

**And** the `DashboardController` uses the same code path for all roles but applies `forUser()` scope, resulting in scoped data for Workers
**And** Workers do NOT see team workload distribution or activity from other team members
**And** the Worker dashboard renders within 3 seconds (NFR5)

### Story 2.4: Dashboard Activity Feed

As a firm owner or manager,
I want to see a stream of recent workspace activity on my dashboard,
So that I can stay aware of team actions, client uploads, and status changes without checking individual declarations.

**Acceptance Criteria:**

**Given** the Owner/Manager dashboard is loaded
**When** the activity feed panel renders (right column on desktop, below main content on mobile)
**Then** the feed shows the 20 most recent workspace events in reverse chronological order
**And** each entry displays: actor avatar/initials, action description, target entity link, and relative timestamp ("il y a 2 heures")
**And** event types include: declaration status changes, document uploads by clients, declaration reassignments, team member role changes, new declarations created
**And** clicking an event navigates to the relevant entity (declaration detail, client page, team page)
**And** the feed uses data from Spatie Activity Log, filtered to the current workspace

**Given** a Worker views the dashboard
**When** the activity feed renders
**Then** the feed shows only activity related to the Worker's assigned declarations (not the entire workspace)

**And** the activity feed is included in the Inertia page props (server-rendered, no separate API call)
**And** on mobile viewports (<768px), the activity feed is accessible via a tab or expandable section (not hidden entirely)
**And** if there is no recent activity, a neutral message is shown: "Aucune activite recente"

---

**Epic 2 Summary:** 4 stories covering the Owner/Manager command center with KPI cards and declarations table, priority alerts panel with deadline color coding, Worker scoped dashboard, and activity feed. All FRs for Epic 2 (FR24, FR25, FR26) are covered.

## Epic 3: Collaboration, Nudge System & Notifications

Owners/Managers can nudge team members on specific declarations with one click. All users have a notification center with direct links to relevant declarations. The system sends reliable email notifications for key events. Bulk notification scheduling enables document request campaigns to multiple clients.

### Story 3.1: Notification Infrastructure Setup

As a developer,
I want the Laravel notification system configured with database and mail channels,
So that all future notification features (nudges, alerts, bulk notifications) have a reliable foundation to build on.

**Acceptance Criteria:**

**Given** Redis queue is configured (from Story 0.4)
**When** the notification infrastructure is set up
**Then** the `notifications` table migration is created and run (`php artisan notifications:table`)
**And** the `User` model confirms the `Notifiable` trait is present (already via Fortify)
**And** a `NotificationType` enum is created in `app/Enums/` with values: `nudge`, `declaration_overdue`, `document_uploaded`, `bulk_notification`, `status_changed`
**And** a base notification pattern is established: all notification classes implement `ShouldQueue`, use `Queueable` trait, and include `workspace_id` in the `toArray()` payload
**And** a `NudgeNotification` class is created in `app/Notifications/` with `database` and `mail` channels
**And** a `DocumentUploadedNotification` class is created with `database` channel only
**And** a `DeclarationOverdueNotification` class is created with `database` and `mail` channels
**And** notification queries are workspace-scoped (filtered via the `workspace_id` in the notification data payload)
**And** all notification classes have queued email sending with up to 3 retries (NFR26)

### Story 3.2: One-Click Nudge System

As a firm owner or manager,
I want to nudge a team member about a specific declaration with one click,
So that I can quickly signal that something needs attention without composing a message or making a phone call.

**Acceptance Criteria:**

**Given** an Owner or Manager is viewing a declaration list (dashboard table or declarations index)
**When** they click the nudge icon on a declaration row
**Then** a NudgePopover appears showing the assigned worker's name and a "Send Nudge" button
**And** clicking "Send Nudge" dispatches a `NudgeNotification` to the assigned worker via `NudgeController@store`
**And** the notification is saved to the database (in-app) AND queued as an email
**And** a success toast confirms: "Nudge envoye a [worker name]"
**And** the popover closes automatically after sending
**And** no message composition is required -- it is a pure signal ("Your attention is needed on this declaration")

**Given** a Worker or a user without nudge permission
**When** they view declaration rows
**Then** the nudge icon is not displayed

**And** the `NudgeController` validates that the sender is Owner or Manager
**And** the `NudgeController` validates that the declaration belongs to the current workspace
**And** the nudge email contains: declaration type, client name, deadline, and a direct link to the declaration detail page
**And** the nudge is logged via Spatie Activity Log (actor: sender, target: declaration, action: "nudged")
**And** duplicate nudges on the same declaration within 1 hour are prevented (debounce) with a user-friendly message

### Story 3.3: Notification Center & Bell

As a team member,
I want to see a notification bell with unread count and access a notification center listing all my notifications,
So that I never miss a nudge, alert, or important event and can navigate directly to the relevant declaration.

**Acceptance Criteria:**

**Given** a user is logged in and has unread notifications
**When** the page loads
**Then** a NotificationBell component in the app header/sidebar shows a badge with the unread notification count
**And** clicking the bell opens a dropdown panel showing the 10 most recent notifications
**And** each notification displays: type icon, description text, relative timestamp ("il y a 5 min"), and read/unread visual state
**And** clicking a notification navigates to the relevant declaration detail page and marks the notification as read
**And** a "Mark all as read" action is available in the dropdown header
**And** a "View all notifications" link at the bottom navigates to `/notifications`

**Given** a user navigates to `/notifications`
**When** the notification index page loads
**Then** all notifications are listed in reverse chronological order with pagination (25 per page)
**And** each notification shows: type icon, full description, timestamp, and read/unread state
**And** notifications can be marked as read individually or in bulk ("Mark all as read")
**And** notifications are scoped to the current workspace (via `workspace_id` in notification data)

**Given** a user has zero notifications
**When** they click the bell or visit the notifications page
**Then** an EmptyState is shown: "Aucune notification"
**And** the bell badge is hidden (no "0" displayed)

### Story 3.4: Bulk Client Notification Scheduling

As a firm worker or manager,
I want to send document request notifications to multiple clients at once,
So that I can efficiently request missing documents without emailing each client individually.

**Acceptance Criteria:**

**Given** a user (Owner/Manager/Worker with assigned declarations) is viewing the declarations list
**When** they select multiple declarations with status `en_attente_client` using row checkboxes
**Then** a BulkActionBar appears at the bottom/top showing: "[N] selected" and a "Notify Clients" button

**Given** the user clicks "Notify Clients" on the BulkActionBar
**When** the confirmation dialog appears
**Then** it shows the count of clients to be notified and a "Send Notifications" confirmation button
**And** clicking "Send" dispatches a POST request to a bulk notification endpoint
**And** each client receives a personalized email with their token-based portal link for document upload
**And** emails are queued individually via Redis (non-blocking -- controller returns immediately)
**And** a success toast confirms: "[N] notifications envoyees"

**Given** 50 client notifications are scheduled
**When** the queue processes them
**Then** all 50 emails are sent within 10 seconds (NFR3)
**And** failed individual sends retry automatically (up to 3 retries per NFR26)
**And** the bulk operation is logged via Spatie Activity Log with the count and actor

**And** Workers can only bulk-notify clients for their own assigned declarations
**And** the existing email templates (DeclarationFileRequestMail) are reused for the notification content

### Story 3.5: Email Notification Enhancement for Key Events

As a platform user,
I want to receive email notifications for important events (nudges, document uploads, status changes),
So that I stay informed about critical actions even when I'm not actively using the platform.

**Acceptance Criteria:**

**Given** a worker receives a nudge from a manager
**When** the `NudgeNotification` processes via queue
**Then** the worker receives an email with: sender name, declaration details (client, type, deadline), and a direct link button ("Voir la declaration")
**And** the email uses a `NudgeNotificationMail` Markdown mailable with professional French copy

**Given** a client uploads a document via the portal
**When** the upload is confirmed
**Then** the assigned worker receives a `DocumentUploadedNotification` in-app (database channel only -- no email for uploads to avoid noise)

**Given** a declaration's status changes
**When** the `DeclarationObserver` fires
**Then** if the new status is `en_attente_client`, the client receives the existing document request email via their token link
**And** if the new status is `ferme`, dashboard cache is invalidated (from Story 2.1)

**Given** email delivery fails
**When** the queue retries
**Then** the system retries up to 3 times within 5 minutes (NFR26)
**And** permanently failed emails are logged to the `failed_jobs` table for monitoring
**And** all queued emails use `ShouldQueue` and `Queueable` traits

**And** email notifications respect business context: no email is sent for minor status transitions (e.g., `created` → `en_cours`)
**And** all email templates include the workspace firm name and logo in the header

---

**Epic 3 Summary:** 5 stories covering notification infrastructure, one-click nudge system, notification center with bell, bulk client notification scheduling, and email enhancement for key events. All FRs for Epic 3 (FR29, FR30, FR31, FR32, FR33) are covered.

## Epic 4: Bulk Operations, Search & Advanced Filtering

Power users can create 50 declarations in one action with type and deadline selection across multiple clients. A persistent filter bar with status, client, assignee, type, and date range filters appears on all list views. Quick search across clients and declarations returns results in under 1 second. Archive has its own top-level navigation with dedicated filters.

### Story 4.1: FilterBar Component & useFilters Composable

As a firm user,
I want a persistent filter bar above data tables that lets me filter by status, client, assignee, type, and date range,
So that I can quickly narrow down the data to exactly what I need without losing my filter selections when navigating.

**Acceptance Criteria:**

**Given** a user is viewing any list page (Declarations, Clients, Archive)
**When** the page loads
**Then** a FilterBar component is rendered above the data table containing:
- Status filter (Select/Combobox with declaration status options)
- Client filter (Combobox with searchable client list)
- Assignee filter (Combobox with workspace team members -- Owner/Manager only, hidden for Workers)
- Type filter (Select with declaration type options)
- Date range filter (DateRangePicker with presets: "Cette semaine", "Ce mois", "Ce trimestre")
- Search input (for quick text search -- wired in Story 4.3)

**And** selecting any filter immediately updates the table data via Inertia `router.get()` with URL query params
**And** no "Apply" button is needed -- filters are instant on change
**And** active filters are shown as removable chips/badges below the filter bar with an "X" to remove each
**And** a "Clear all filters" link appears when any filter is active
**And** filter state persists in the URL: navigating away and pressing browser back restores filters
**And** a `useFilters` composable in `resources/js/composables/useFilters.ts` manages filter state, URL param serialization, and reset logic
**And** pagination resets to page 1 when any filter changes
**And** the FilterBar uses `preserveState: true` and `preserveScroll: true` on Inertia requests
**And** on mobile (<768px), the FilterBar collapses behind an expandable toggle ("Filtres")
**And** shadcn-vue `combobox`, `select`, `calendar`, and `popover` components are installed if not already present

### Story 4.2: Apply FilterBar to Declarations & Clients Pages

As a firm user,
I want filtering and sorting on the Declarations and Clients list pages,
So that I can find specific declarations by status, deadline, or assignee and sort by the column that matters most.

**Acceptance Criteria:**

**Given** the FilterBar component exists (from Story 4.1)
**When** the Declarations index page (`/declarations`) loads
**Then** the FilterBar is integrated above the declarations table
**And** the `DeclarationController@index` reads filter params from the Request and applies Eloquent scopes via an `applyFilters()` method:
- `status` → `where('status', $status)`
- `assignee` → `where('assigned_to', $assignee)` (or `forUser()` scope for Workers)
- `client_id` → `where('client_id', $clientId)`
- `type` → `where('type', $type)`
- `deadline_from` / `deadline_to` → `whereBetween('deadline', [...])`
**And** default sort is `deadline ASC` (most urgent first)
**And** column headers are clickable for sorting (ascending → descending → unsorted) with arrow indicators
**And** the table uses server-side pagination (25 rows per page default, configurable 25/50/100)
**And** a "Showing X of Y declarations" count is displayed below the table

**Given** the Clients index page (`/clients`) loads
**When** filters are applied
**Then** the FilterBar shows relevant client filters (status, name search)
**And** default sort is `name ASC`
**And** the `ClientController@index` applies the same `applyFilters()` pattern

**And** Workers see the assignee filter pre-set to themselves (non-removable)
**And** empty filter results show an EmptyState: "Aucun resultat pour ces filtres" with a "Clear filters" button

### Story 4.3: Quick Search with MySQL FULLTEXT

As a firm user,
I want to type a few characters into a search box and instantly find clients or declarations matching my query,
So that I can quickly locate a specific client or declaration without scrolling through tables.

**Acceptance Criteria:**

**Given** a user types into the SearchInput field in the FilterBar
**When** they type at least 2 characters
**Then** the search query is sent as a URL param (`?search=query`) via the `useFilters` composable
**And** the backend uses `MATCH...AGAINST` in natural language mode on FULLTEXT indexed columns
**And** results are returned within 1 second (NFR6)

**Given** the FULLTEXT indexes need to be created
**When** the migration runs
**Then** FULLTEXT indexes are added on:
- `declarations` table: composite index on declaration type and any notes/description columns
- `clients` table: composite index on client name, company name, and any searchable text fields
**And** the migration uses `DB::statement()` for MySQL FULLTEXT index creation

**Given** a search returns no results
**When** the table updates
**Then** an EmptyState is shown: "Aucun resultat pour '[query]'" with a suggestion to adjust the search

**And** search works in combination with other active filters (additive filtering)
**And** the SearchInput has a debounce of 300ms to avoid excessive requests while typing
**And** a clear (X) button inside the search input clears the search term
**And** search is workspace-scoped (always combined with `workspace_id` filter)

### Story 4.4: Bulk Declaration Creation

As a firm owner or manager,
I want to create declarations for multiple clients at once by selecting clients, choosing a type, and setting a deadline,
So that I can set up 50 TVA declarations in minutes instead of creating them one by one.

**Acceptance Criteria:**

**Given** an Owner or Manager navigates to `/declarations/bulk-create`
**When** the bulk creation page loads
**Then** a multi-step form is displayed:
1. **Select Clients:** Searchable client list with checkboxes, "Select all" toggle, selected count displayed
2. **Configure Declaration:** Type selection (Combobox with declaration types), deadline date picker, optional assignee selection (Combobox with team members)
3. **Review & Confirm:** Summary showing count of declarations to be created, type, deadline, assignee

**Given** the user confirms the bulk creation
**When** they click "Create [N] Declarations"
**Then** all declarations are created within a `DB::transaction()` (all-or-nothing: if any single declaration fails validation or creation, the entire batch is rolled back and zero declarations are created — no partial creation)
**And** each declaration is created with: `workspace_id`, `client_id`, `type`, `deadline`, `assigned_to`, `status: created`
**And** the controller returns immediately with a redirect and success toast: "[N] declarations creees"
**And** if `notify_clients` is checked, client notification emails are queued via Redis (non-blocking)

**Given** 50 declarations are being created
**When** the transaction completes
**Then** all 50 are created within 10 seconds (NFR2)
**And** the dashboard cache is invalidated for the workspace

**Given** a Worker navigates to the bulk creation page
**When** the page loads
**Then** they receive a 404 (Workers cannot bulk-create)

**And** the `BulkStoreDeclarationRequest` validates: at least 1 client selected, valid type, valid deadline (not in the past), valid assignee (belongs to workspace)
**And** the bulk creation is logged via Spatie Activity Log with count, type, and actor
**And** a "Bulk Create" button is accessible from the Declarations index page header (Owner/Manager only)

### Story 4.5: Archive Navigation with Dedicated Filters

As a firm user,
I want the Archive section accessible as a top-level navigation item with its own dedicated filters and search,
So that I can browse historical declarations separately from active ones with appropriate filtering options.

**Acceptance Criteria:**

**Given** a user is logged in
**When** they view the sidebar navigation
**Then** "Archive" appears as a top-level nav item (below Declarations, above Team/Settings)
**And** clicking "Archive" navigates to `/archive`

**Given** the user is on the Archive index page
**When** the page loads
**Then** the FilterBar is displayed with filters relevant to archived declarations: client, type, date range (archive date), original assignee, search
**And** the table shows archived declarations using `Declaration::archived()->forUser()` scope
**And** archived rows have a visually muted/desaturated styling to distinguish them from active declarations (FR50)
**And** each row shows: Client, Type, Original Deadline, Archived Date, Status badge ("Archive" in gray)
**And** default sort is `archived_at DESC` (most recently archived first)
**And** server-side pagination with the same 25/50/100 options

**Given** a Worker views the Archive
**When** the page loads
**Then** they see only archived declarations that were originally assigned to them

**And** the `ArchiveController@index` reads filters from Request and applies scopes identically to the declarations filter pattern
**And** the Archive page uses the same FilterBar component and `useFilters` composable (reusable)
**And** an EmptyState is shown if no archived declarations exist: "Aucune declaration archivee"

---

**Epic 4 Summary:** 5 stories covering the FilterBar component with URL persistence, filter integration on Declarations and Clients pages, quick search with MySQL FULLTEXT, bulk declaration creation, and Archive as a top-level nav with dedicated filters. All FRs for Epic 4 (FR17, FR41, FR42, FR43, FR44) are covered.

## Epic 5: Archive System & Document Preview

Completed declarations are automatically archived when closed, preserving full history (documents, messages, status changes, activity log). Users can browse, filter, and search the archive. Archive detail pages show read-only snapshots with in-app document preview (PDF.js). Owners can re-open archived items with audit trail. Bulk ZIP download and 10-year retention policy ensure compliance.

### Story 5.1: Auto-Archive on Close & History Preservation

As a firm owner or manager,
I want declarations to be automatically archived when I mark them as closed, with their full history preserved,
So that completed work is moved out of active views while retaining a complete audit trail for future reference.

**Acceptance Criteria:**

**Given** a declaration has status `ferme` (closed)
**When** the system processes the closure (via `DeclarationObserver` or a scheduled command)
**Then** the declaration's `archived_at` timestamp is set to `now()`
**And** the declaration's `status` transitions to `archive`
**And** the declaration disappears from the active declarations list (`/declarations`) and appears in the archive (`/archive`)

**Given** a declaration is being archived
**When** the archive process runs
**Then** all related data is preserved in place (no data movement to a separate table):
- All Spatie Media Library attachments remain linked to the declaration
- All Spatie Activity Log entries remain linked to the declaration
- All status change history is preserved
- All assignment history is preserved
- The `archived_at` timestamp is recorded
**And** dashboard cache is invalidated for the workspace (reusing the cache invalidation from Story 2.1)

**Given** a declaration is already archived
**When** a user attempts to edit it via API
**Then** a 404 response is returned (per NFR9: authorization violations return 404 to prevent tenant/state leakage)
**And** a `DeclarationPolicy@update` check rejects any update on archived declarations

**And** the archive transition is logged via Spatie Activity Log (actor: user who closed it, action: "archived", target: declaration)
**And** the migration adds an `archived_at` nullable timestamp column to the `declarations` table
**And** an Eloquent scope `scopeArchived()` filters `whereNotNull('archived_at')` and `scopeActive()` filters `whereNull('archived_at')`

### Story 5.2: Archive Detail Page — Read-Only Snapshot

As a firm user,
I want to view an archived declaration's complete details in a read-only page showing all historical data,
So that I can review past work, verify documents, and trace the full lifecycle without risk of accidental edits.

**Acceptance Criteria:**

**Given** a user navigates to `/archive/{declaration}` for an archived declaration
**When** the page loads
**Then** the ArchiveShow page renders a read-only view with:
- Client name and details (linked to client profile)
- Declaration type and original deadline
- Current status badge ("Archive" in gray/muted styling per FR50)
- A `DeclarationStatusStepper` component showing the full lifecycle progression (Created → En cours → En attente client → Termine → Ferme → Archive) with timestamps for each transition
- Assigned worker name and reassignment history
- All attached documents listed with file name, size, upload date, and uploader name
- Activity log timeline showing all actions taken on this declaration (status changes, assignments, nudges, uploads)

**Given** the page is read-only
**When** a user views the archive detail
**Then** all edit buttons, status change dropdowns, and action buttons are hidden
**And** the page header shows a banner: "Declaration archivee — consultation uniquement"
**And** document download links remain functional (individual file downloads)

**Given** a Worker views an archived declaration
**When** it was not originally assigned to them
**Then** they receive a 404 (per NFR9: Workers cannot discover existence of declarations not assigned to them)

**And** the `ArchiveController@show` loads the declaration with eager-loaded relations: `client`, `assignedTo`, `media`, `activities`
**And** the `DeclarationStatusStepper` is a reusable Vue component that renders a horizontal step indicator with status labels and timestamps
**And** the page uses the muted/desaturated visual styling established in Story 4.5 for archived items

### Story 5.3: In-App Document Preview

As a firm user,
I want to preview documents (PDFs and images) directly within the application without downloading them,
So that I can quickly review attached files while staying in context on the declaration detail page.

**Acceptance Criteria:**

**Given** a user is viewing a declaration detail page (active or archived) that has attached documents
**When** they click on a document in the attachments list
**Then** a modal/overlay opens showing an in-app preview of the document
**And** PDF files are rendered using PDF.js (`pdfjs-dist` npm package) in a `<canvas>` element within a `DocumentPreview` component
**And** image files (JPG, PNG, WEBP) are rendered in an `<img>` tag with zoom controls
**And** the preview modal includes: file name in the header, page navigation for multi-page PDFs (prev/next/page count), zoom in/out controls, a "Download" button, and a close (X) button

**Given** a document format is not previewable (e.g., .xlsx, .docx, .zip)
**When** the user clicks on it
**Then** the file downloads directly instead of opening a preview
**And** the attachment list shows a "preview" icon only for previewable formats (PDF, JPG, PNG, WEBP)

**Given** a PDF has more than 1 page
**When** the preview modal opens
**Then** the first page is displayed by default
**And** page navigation shows "Page 1 of N" with next/prev buttons
**And** the user can jump to a specific page via a page number input

**And** the `DocumentPreview` component is located at `resources/js/Components/DocumentPreview.vue`
**And** PDF.js is loaded lazily (dynamic import) to avoid increasing the main bundle size
**And** a loading spinner is shown while the PDF renders
**And** preview works on both active declaration detail pages and archive detail pages (from Story 5.2)

### Story 5.4: Re-Open Archived Declaration

As a firm owner,
I want to re-open an archived declaration and return it to active status,
So that I can resume work on a declaration that was prematurely closed or needs additional attention.

**Acceptance Criteria:**

**Given** an Owner is viewing an archived declaration detail page (`/archive/{declaration}`)
**When** they click the "Reouvrir" button
**Then** a confirmation dialog appears: "Etes-vous sur de vouloir reouvrir cette declaration? Elle sera remise dans la liste active avec le statut 'En cours'."
**And** the dialog has "Confirmer" and "Annuler" buttons

**Given** the Owner confirms the re-open action
**When** the request is processed
**Then** the declaration's `archived_at` is set to `null`
**And** the declaration's `status` is set to `en_cours`
**And** the declaration disappears from the Archive list and reappears in the active Declarations list
**And** a success toast confirms: "Declaration rouverte avec succes"
**And** the user is redirected to the active declaration detail page (`/declarations/{declaration}`)

**Given** a Manager or Worker attempts to re-open an archived declaration
**When** they view the archive detail page
**Then** the "Reouvrir" button is not displayed
**And** if they attempt via API, a 404 response is returned (per NFR9: authorization violations return 404)

**And** the re-open action is logged via Spatie Activity Log with: actor (Owner), action "reopened", target (declaration), and a properties JSON including `previous_archived_at` timestamp
**And** dashboard cache is invalidated for the workspace
**And** the `DeclarationPolicy@reopen` method checks `$user->isOwner()` in the current workspace
**And** the re-open endpoint is `POST /archive/{declaration}/reopen`

### Story 5.5: Bulk ZIP Download & Retention Policy

As a firm user,
I want to download all documents from an archived declaration as a single ZIP file, and trust that archived data is retained for at least 10 years,
So that I can easily export complete declaration records and comply with Moroccan regulatory retention requirements.

**Acceptance Criteria:**

**Given** a user is viewing an archived declaration detail page with multiple attached documents
**When** they click "Telecharger tout (ZIP)"
**Then** a ZIP file is generated server-side containing all attached documents from the declaration
**And** the ZIP file name follows the pattern: `{client_name}_{declaration_type}_{deadline_date}.zip`
**And** the file downloads via a streamed response (`ZipStream` or `ZipArchive` with `StreamedResponse`)
**And** a loading indicator is shown while the ZIP is being prepared

**Given** an archived declaration has no documents attached
**When** the user views the detail page
**Then** the "Telecharger tout (ZIP)" button is hidden/disabled

**Given** the ZIP file exceeds 100MB
**When** the generation starts
**Then** the download uses streaming (chunked response) to avoid memory issues
**And** the server does not buffer the entire ZIP in memory

**Given** the 10-year retention policy (FR51)
**When** archived declarations are stored
**Then** no automated deletion mechanism exists -- archived data is retained indefinitely
**And** the `archived_at` timestamp enables future retention policy queries (e.g., `where archived_at < now() - 10 years`)
**And** a `declarations:check-retention` Artisan command is scaffolded (no-op implementation) that can be scheduled in the future to flag declarations past the 10-year mark
**And** the retention policy is documented in a code comment on the command class

**And** the ZIP download endpoint is `GET /archive/{declaration}/download-zip`
**And** the endpoint validates that the user has access to the declaration (workspace + role scoping)
**And** the download action is logged via Spatie Activity Log (actor, action: "downloaded_zip", target: declaration)

---

**Epic 5 Summary:** 5 stories covering auto-archive with history preservation, read-only archive detail with status stepper, in-app document preview via PDF.js, owner-only re-open with audit trail, and bulk ZIP download with 10-year retention scaffolding. All FRs for Epic 5 (FR21, FR22, FR23, FR45, FR46, FR47, FR48, FR49, FR50, FR51) are covered.

## Epic 6: Platform Administration & Subscription Enforcement

SaaS admin can monitor platform health via a dedicated dashboard (workspace count, user count, storage, system health). Admin can view and respond to support tickets, manage platform configuration, and enforce subscription tier limits across all workspaces.

### Story 6.1: SaaS Admin Role & Admin Guard

As a platform administrator,
I want a dedicated admin role and protected admin area separate from workspace roles,
So that only authorized platform administrators can access system-wide management features.

**Acceptance Criteria:**

**Given** the admin role needs to be established
**When** the migration runs
**Then** an `is_admin` boolean column (default `false`) is added to the `users` table
**And** this column is independent of workspace roles (a user can be both a workspace Owner and a platform admin)

**Given** an admin user navigates to `/admin`
**When** the request is processed
**Then** an `AdminMiddleware` checks `auth()->user()->is_admin === true`
**And** if the user is an admin, they access the admin area
**And** if the user is not an admin, they receive a 404 response (not 403, to avoid revealing the admin area exists)

**Given** an admin accesses the admin area
**When** the admin layout loads
**Then** a dedicated `AdminLayout.vue` is rendered with its own sidebar navigation containing: Dashboard, Workspaces, Tickets, Settings
**And** the admin layout is visually distinct from the workspace layout (e.g., dark sidebar, "Admin" badge in header)
**And** admin routes are grouped under `Route::middleware(['auth', 'admin'])->prefix('admin')->group()`

**Given** an admin wants to return to their workspace
**When** they click "Back to Workspace" in the admin header
**Then** they are redirected to their default workspace dashboard

**And** the `AdminMiddleware` is registered in `bootstrap/app.php`
**And** a database seeder creates at least one admin user for development/staging
**And** the `is_admin` attribute is hidden from API responses (`$hidden` on User model)

### Story 6.2: Platform Dashboard & Workspace Overview

As a platform administrator,
I want a dashboard showing platform-wide metrics and a detailed workspace listing,
So that I can monitor platform health, track growth, and identify workspaces that need attention.

**Acceptance Criteria:**

**Given** an admin navigates to `/admin`
**When** the dashboard loads
**Then** summary cards are displayed showing:
- Total workspaces (active + trial)
- Total users across all workspaces
- Total storage used (sum of all Spatie Media Library files) with percentage of capacity
- Active workspaces vs trial workspaces (with counts and percentages)
- System health indicator (queue size, failed jobs count)
**And** each metric card shows the current value and a trend indicator (vs last 30 days)

**Given** an admin navigates to `/admin/workspaces`
**When** the workspace index page loads
**Then** a table displays all workspaces with columns: Firm Name, Owner Email, Plan Tier, Team Count, Client Count, Declaration Count, Storage Used, Created Date, Status (active/trial/expired)
**And** the table is sortable by any column (click column header)
**And** a search input filters workspaces by firm name or owner email
**And** server-side pagination with 25/50/100 rows per page

**Given** an admin clicks on a workspace row
**When** the workspace detail page loads (`/admin/workspaces/{workspace}`)
**Then** a detailed view shows: firm name, owner details, plan tier, all team members with roles, client count, declaration count (by status), storage breakdown, creation date, last activity date
**And** usage metrics are displayed as progress bars against tier limits (e.g., "5/10 team members")

**And** dashboard metrics are cached in Redis with a 5-minute TTL to avoid expensive aggregate queries on every page load
**And** the `AdminDashboardController` uses `DB::table()` aggregate queries (COUNT, SUM) rather than loading all models
**And** the workspace detail uses eager loading to avoid N+1 queries

### Story 6.3: Support Ticket Inbox

As a platform administrator,
I want to view and respond to support tickets submitted by workspace owners,
So that I can provide timely support and track issue resolution across the platform.

**Acceptance Criteria:**

**Given** the support ticket system needs to be created
**When** the migration runs
**Then** a `support_tickets` table is created with: `id`, `workspace_id`, `user_id`, `subject`, `body` (text), `status` (enum: open, in_progress, resolved, closed), `priority` (enum: low, normal, high), `timestamps`
**And** a `ticket_replies` table is created with: `id`, `support_ticket_id`, `user_id`, `body` (text), `is_admin_reply` (boolean), `timestamps`

**Given** a workspace Owner navigates to Settings
**When** they click "Support" or "Contacter le support"
**Then** a support ticket form is displayed with: Subject (text input), Message (textarea), Priority selector (low/normal/high)
**And** submitting the form creates a new ticket with status `open`
**And** a success toast confirms: "Ticket envoye — nous vous repondrons sous 24h"
**And** the Owner can view their submitted tickets and replies in a "Mes tickets" section

**Given** an admin navigates to `/admin/tickets`
**When** the ticket inbox loads
**Then** all tickets are listed with: Ticket ID, Subject, Workspace (firm name), Submitter, Status badge, Priority badge, Created Date, Last Reply Date
**And** tickets are filterable by status and priority
**And** default sort is: open tickets first, then by priority (high → low), then by created date (oldest first)

**Given** an admin clicks on a ticket
**When** the ticket detail page loads (`/admin/tickets/{ticket}`)
**Then** the full conversation thread is displayed (original message + all replies in chronological order)
**And** the admin can type a reply and submit it
**And** the admin can change the ticket status via a dropdown (open → in_progress → resolved → closed)
**And** when the admin replies, the ticket owner receives an email notification with the reply content and a link to view the ticket

**And** Workers and Managers cannot submit support tickets (Owner-only feature)
**And** the `SupportTicketPolicy` ensures workspace-scoped access (Owners see only their tickets, admins see all)

### Story 6.4: Platform Configuration Management

As a platform administrator,
I want to manage global platform settings from a dedicated admin page,
So that I can adjust trial durations, storage limits, feature flags, and other configuration without code deployments.

**Acceptance Criteria:**

**Given** the platform settings need a storage mechanism
**When** the migration runs
**Then** a `platform_settings` table is created with: `key` (string, unique, primary), `value` (JSON), `description` (string, nullable), `timestamps`
**And** a `PlatformSetting` model is created with a static helper: `PlatformSetting::get('key', $default)` that reads from cache-first (Redis) and falls back to database
**And** `PlatformSetting::set('key', $value)` updates the database and busts the cache

**Given** an admin navigates to `/admin/settings`
**When** the settings page loads
**Then** configurable settings are displayed in grouped sections:
- **Trial:** Default trial duration (days), trial features list
- **Storage:** Storage limit per tier (GB), maximum file upload size (MB)
- **Features:** Feature flags as toggles (e.g., bulk operations enabled, document preview enabled, client portal enabled)
- **Email:** Default sender name, support email address
**And** each setting shows its current value with an inline edit control (input, toggle, or select as appropriate)

**Given** an admin changes a setting value
**When** they click "Save" (per section or individual save)
**Then** the setting is updated in the database and Redis cache is invalidated
**And** a success toast confirms: "Parametre mis a jour"
**And** the change is logged via Spatie Activity Log (actor: admin, action: "updated_setting", properties: key, old_value, new_value)

**And** default settings are seeded via a `PlatformSettingsSeeder` with sensible defaults
**And** the `PlatformSetting::get()` method handles missing keys gracefully by returning the provided default
**And** settings are cached with a "platform_settings" cache tag for easy bulk invalidation

### Story 6.5: Subscription Tier Limit Enforcement

As a platform operator,
I want the system to enforce subscription tier limits on team members, clients, storage, and features,
So that workspaces operate within their plan's boundaries and are prompted to upgrade when they reach limits.

**Acceptance Criteria:**

**Given** subscription tiers need to be defined
**When** the migration runs
**Then** a `subscription_tiers` table is created with: `id`, `slug` (unique: trial, starter, professional, enterprise), `name`, `limits` (JSON column containing: max_team_members, max_clients, max_storage_gb, features array), `price_monthly` (nullable, for display), `timestamps`
**And** a `tier_id` foreign key is added to the `workspaces` table (default: trial tier)
**And** a `SubscriptionTier` model is created with a `hasLimit('key')` helper and a `getLimit('key')` accessor

**Given** a workspace Owner tries to add a team member
**When** the current team count equals or exceeds the tier's `max_team_members` limit
**Then** the action is blocked with a 422 response: "Limite atteinte — votre plan autorise [N] membres maximum"
**And** on the frontend, a `LimitReachedModal` component is displayed showing: current usage, tier limit, and an "Upgrade" CTA button linking to the billing/plan page

**Given** a workspace tries to create a new client
**When** the current client count equals or exceeds the tier's `max_clients` limit
**Then** the same limit enforcement pattern applies (422 + LimitReachedModal)

**Given** a workspace tries to upload a document
**When** the total storage used equals or exceeds the tier's `max_storage_gb` limit
**Then** the upload is blocked with a 422 response: "Limite de stockage atteinte"
**And** the LimitReachedModal shows current storage usage vs limit

**Given** a tier restricts certain features (e.g., bulk operations only on Professional+)
**When** a user in a lower tier attempts to access a restricted feature
**Then** the feature button/link shows a lock icon with "Pro" badge
**And** clicking it displays the LimitReachedModal with feature comparison

**Given** an admin navigates to `/admin/workspaces/{workspace}/plan`
**When** the plan management page loads
**Then** the admin can change the workspace's tier via a dropdown
**And** changing the tier takes effect immediately
**And** the change is logged via Spatie Activity Log

**And** limit checks are implemented via a `SubscriptionLimitService` that is called in relevant controllers/policies
**And** a `workspace.tier` relationship is eager-loaded on the Workspace model (cached per request)
**And** the `LimitReachedModal` is a reusable Vue component at `resources/js/Components/LimitReachedModal.vue`
**And** tier definitions are seeded via `SubscriptionTierSeeder` with default limits matching the PRD's subscription tiers

---

**Epic 6 Summary:** 5 stories covering admin role/guard setup, platform dashboard with workspace overview, support ticket inbox with threaded replies, platform configuration management with cached settings, and subscription tier limit enforcement with upgrade prompts. All FRs for Epic 6 (FR27, FR28, FR56, FR57, FR58) are covered.

## Epic 7: Production Infrastructure & Deployment

The platform runs reliably in production with EU-hosted infrastructure (CNDP compliance), S3-compatible encrypted file storage, Amazon SES email delivery (eu-west-1), Laravel Forge deployment, Pulse + Sentry monitoring, and automated backup procedures. All reliability and security NFRs are met for launch.

### Story 7.1: Encrypted File Storage with S3-Compatible Backend

As a platform operator,
I want all uploaded documents stored in an S3-compatible bucket with server-side encryption and secure access controls,
So that client documents are protected at rest and in transit, meeting CNDP compliance and data security requirements.

**Acceptance Criteria:**

**Given** the file storage needs to be configured for production
**When** the storage configuration is set up
**Then** `config/filesystems.php` defines an `s3` disk pointing to an EU-hosted S3-compatible bucket (e.g., Scaleway Object Storage eu-west or AWS S3 eu-west-1)
**And** server-side encryption is enabled (AES-256 / `ServerSideEncryption: AES256`) on the bucket policy
**And** the `FILESYSTEM_DISK` environment variable is set to `s3` in production

**Given** Spatie Media Library is configured
**When** files are uploaded via the application
**Then** Media Library stores files on the `s3` disk
**And** the `media` table stores the disk name and path but never the full URL
**And** all file access goes through signed URLs (time-limited, default 30 minutes) generated via `$media->getTemporaryUrl(now()->addMinutes(30))`

**Given** a user requests a document
**When** the application generates a download/preview URL
**Then** a `MediaPolicy` validates: the user belongs to the same workspace as the declaration, and role-based access is enforced (Workers only access their assigned declarations' documents)
**And** the signed URL expires after 30 minutes
**And** direct bucket access is blocked (bucket policy denies public access)

**Given** the storage needs to handle production scale
**When** files are uploaded
**Then** uploads use multipart upload for files >5MB
**And** the maximum upload size is enforced at 20MB per file (configurable via `PlatformSetting` from Story 6.4)
**And** CORS is configured on the bucket to allow uploads from the application domain only

**And** the `.env.example` is updated with all required S3 configuration keys (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_DEFAULT_REGION`, `AWS_BUCKET`, `AWS_ENDPOINT`)
**And** local development continues to use the `local` disk (no S3 dependency for development)
**And** a `storage:verify` Artisan command is created that tests bucket connectivity and encryption settings

### Story 7.2: Email Delivery via Amazon SES

As a platform operator,
I want email delivery configured through Amazon SES with proper authentication and monitoring,
So that transactional emails (nudges, notifications, client portal links) are delivered reliably with >99% success rate.

**Acceptance Criteria:**

**Given** email delivery needs production configuration
**When** the mail driver is configured
**Then** `config/mail.php` uses the `ses` driver with region `eu-west-1`
**And** the `MAIL_MAILER` environment variable is set to `ses` in production
**And** the AWS SES credentials are configured via environment variables (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, shared with S3 if same account)

**Given** the sending domain needs verification
**When** SES is set up
**Then** SPF, DKIM, and DMARC DNS records are documented in a `docs/email-setup.md` checklist
**And** the `MAIL_FROM_ADDRESS` and `MAIL_FROM_NAME` are set to the platform's verified sending identity
**And** SES is moved out of sandbox mode (production access requested)

**Given** emails are sent from the application
**When** the queue processes email jobs
**Then** all emails use `ShouldQueue` and are dispatched via the Redis queue (non-blocking)
**And** failed email jobs retry up to 3 times with exponential backoff (NFR26)
**And** permanently failed emails land in the `failed_jobs` table

**Given** SES sends bounce or complaint notifications
**When** an SNS webhook receives the event
**Then** a `SesWebhookController` processes bounce/complaint notifications at `POST /webhooks/ses`
**And** bounced email addresses are logged for review
**And** complaint addresses are flagged to prevent future sends
**And** the webhook endpoint validates the SNS message signature for security

**And** email rate limiting is configured to stay within SES sending limits (default: 14 emails/second)
**And** local development uses the `log` or `mailtrap` driver (no SES dependency)
**And** a `mail:test` Artisan command sends a test email to verify SES configuration

### Story 7.3: Laravel Forge Deployment & CI Pipeline

As a platform operator,
I want automated zero-downtime deployments via Laravel Forge on an EU-hosted VPS,
So that code changes are deployed reliably without interrupting active users, and the infrastructure is CNDP-compliant.

**Acceptance Criteria:**

**Given** a production server needs provisioning
**When** Laravel Forge provisions the server
**Then** the VPS is hosted in an EU data center (e.g., Scaleway Paris, Hetzner Helsinki, OVH Gravelines) for CNDP compliance (NFR13)
**And** the server runs: PHP 8.3+, MySQL 8.0+, Redis 7+, Nginx, Node.js 20+, Supervisor (for queue workers)
**And** SSL is provisioned via Let's Encrypt with auto-renewal

**Given** a deployment is triggered (via Forge deploy button or git push to `main`)
**When** the deployment script runs
**Then** zero-downtime deployment is achieved via the following sequence:
1. `git pull origin main`
2. `composer install --no-dev --optimize-autoloader`
3. `npm ci && npm run build`
4. `php artisan migrate --force`
5. `php artisan config:cache && php artisan route:cache && php artisan view:cache`
6. `php artisan queue:restart` (graceful restart)
7. Symlink swap (Forge's built-in zero-downtime)
**And** if any step fails, the deployment aborts and the previous release remains active
**And** deployment notifications are sent to a configured channel (email or Slack webhook)

**Given** the production environment needs configuration
**When** `.env` is set on the server
**Then** all environment variables are configured: APP_ENV=production, APP_DEBUG=false, database credentials, Redis connection, S3 credentials, SES credentials, Sentry DSN
**And** `APP_KEY` is generated once and securely stored
**And** session, cache, and queue drivers are all set to `redis`

**Given** Nginx needs configuration
**When** the server is set up
**Then** Nginx is configured with: gzip compression, proper cache headers for static assets (30-day max-age for versioned assets), security headers (X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security)
**And** request body size limit is set to 25MB (to accommodate file uploads)

**And** a Supervisor configuration runs at least 2 queue workers (`php artisan queue:work --tries=3 --timeout=90`)
**And** a cron entry runs `php artisan schedule:run` every minute
**And** the deployment script is documented in `docs/deployment.md`

### Story 7.4: Monitoring, Alerting & Error Tracking

As a platform operator,
I want real-time monitoring of application health, performance, and errors,
So that I can detect and respond to issues before they impact users, maintaining 99.5% uptime (NFR21).

**Acceptance Criteria:**

**Given** error tracking needs to be configured
**When** Sentry is integrated
**Then** the `sentry/sentry-laravel` package is installed and configured with a DSN via `SENTRY_DSN` environment variable
**And** all unhandled exceptions are reported to Sentry with: stack trace, user context (id, workspace_id), request data, and environment tags
**And** performance monitoring is enabled with a sample rate of 0.2 (20% of transactions) in production
**And** sensitive data (passwords, tokens) is scrubbed from Sentry reports via `before_send` callback

**Given** application health needs monitoring
**When** a health check endpoint is accessed at `GET /health`
**Then** the endpoint returns JSON with status checks for: database connectivity, Redis connectivity, queue health (pending job count), storage accessibility
**And** if all checks pass: HTTP 200 with `{"status": "healthy"}`
**And** if any check fails: HTTP 503 with `{"status": "unhealthy", "checks": {...}}` detailing which check failed
**And** the endpoint is publicly accessible (no auth) for external uptime monitoring services
**And** a `HealthCheckController` runs each check with a 5-second timeout

**Given** Laravel Pulse is installed for internal monitoring
**When** an admin navigates to `/admin/pulse` (or Pulse's default route, protected by admin middleware)
**Then** the Pulse dashboard shows: slow queries (>100ms), cache hit/miss rates, queue throughput and wait times, active users, and exception counts
**And** Pulse data is stored in its own database table (default configuration)
**And** Pulse data retention is set to 7 days

**And** structured logging is configured: `LOG_CHANNEL=stack` with `daily` channel in production (14-day retention, max 500MB)
**And** critical log events include workspace_id and user_id context via a `LogContextMiddleware`
**And** alert thresholds are documented: error rate >1% triggers Sentry alert, queue depth >100 triggers notification, p95 response time >2s triggers review

### Story 7.5: Automated Backups & Disaster Recovery

As a platform operator,
I want automated daily backups of the database and uploaded files with a tested restore procedure,
So that I can recover from data loss or corruption with zero data loss for completed transactions (NFR28).

**Acceptance Criteria:**

**Given** the backup system needs to be configured
**When** `spatie/laravel-backup` is installed
**Then** `config/backup.php` is configured to:
- Back up the MySQL database (full dump)
- Back up the S3 media files (or reference them if S3 versioning is enabled)
- Store backups in a separate S3 bucket (`backups` bucket, EU-hosted)
- Encrypt backup archives with a backup password (via `backup.backup.password`)

**Given** the backup schedule is configured
**When** the Laravel scheduler runs
**Then** `backup:run` executes daily at 02:00 UTC (low-traffic period)
**And** `backup:clean` runs daily at 03:00 UTC to enforce retention policy
**And** retention policy: 30 daily backups, 12 monthly backups (first of month), 5 yearly backups (January 1st)
**And** `backup:monitor` runs daily at 04:00 UTC and sends a notification if the latest backup is older than 26 hours

**Given** a backup fails
**When** `backup:run` encounters an error
**Then** a notification is sent via mail to the configured admin email
**And** the failure is logged to Sentry as a warning-level event
**And** the `BackupFailedNotification` includes: error message, server name, timestamp

**Given** a disaster recovery scenario
**When** a restore is needed
**Then** a documented restore procedure exists in `docs/disaster-recovery.md` covering:
1. Provision new server via Forge (or restore existing)
2. Download latest backup from S3 backup bucket
3. Decrypt and extract backup archive
4. Restore MySQL dump: `mysql -u root database < dump.sql`
5. Verify data integrity (row counts, latest timestamps)
6. Update DNS if server IP changed
7. Verify application health via `/health` endpoint
**And** the restore procedure is tested at least once on staging before launch

**Given** zero data loss is required for completed transactions (NFR28)
**When** the database is configured
**Then** MySQL binary logging is enabled (`binlog_format=ROW`) for point-in-time recovery between daily backups
**And** binary logs are retained for 7 days
**And** the point-in-time recovery procedure is documented in `docs/disaster-recovery.md`

**And** backup file naming follows: `{app_name}_{date}_{time}.zip`
**And** the backup S3 bucket has a lifecycle policy matching the retention configuration
**And** local development does not run backup schedules

---

**Epic 7 Summary:** 5 stories covering S3-compatible encrypted file storage with signed URLs, Amazon SES email delivery with bounce handling, Laravel Forge zero-downtime deployment on EU-hosted infrastructure, Pulse + Sentry monitoring with health checks, and automated backups with documented disaster recovery. All NFRs for Epic 7 (NFR7, NFR13, NFR14, NFR19, NFR21-NFR28) are covered.
