diff --git a/_bmad-output/planning-artifacts/epics.md b/_bmad-output/planning-artifacts/epics.md index 6655c12..6b2a878 100644 --- a/_bmad-output/planning-artifacts/epics.md +++ b/_bmad-output/planning-artifacts/epics.md @@ -1,6 +1,6 @@ --- -stepsCompleted: [1] -inputDocuments: ['prd.md', 'architecture.md'] +stepsCompleted: [1, 2, 3] +inputDocuments: ['prd.md', 'architecture.md', 'ux-design-specification.md'] workflowType: 'epics-and-stories' project_name: 'pictureFrame' user_name: 'Matt.edholm' @@ -107,11 +107,933 @@ Architecture-derived technical requirements: - **Enums:** PHP backed enums for RenderStatus, TokenType, Orientation - **Repository naming:** `findActive*` prefix on all soft-delete-aware methods - **No OTA firmware:** API contract is stable by design; breaking changes require physical reflash +- **Frontend stack:** Vue 3 SPA (`frontend/` directory), Vite + TypeScript strict, SCSS modules per SFC, Konva.js + Vue-Konva for sticker canvas; authenticated app served via `SpaController` catch-all + +UX-derived requirements: +- **WCAG 2.1 AA compliance** across all authenticated app flows +- **Mobile-first:** iOS Safari + Android Chrome are primary targets; all flows designed touch-first; 44px minimum touch targets +- **Responsive breakpoints:** tablet 640px, desktop 960px; library grid 2→3→4 col; bottom nav replaced by top nav at desktop +- **Theming:** 6 user-selectable themes (Warm Craft, Playful Pop, Sage & Cream, Dusty Mauve, Ocean Dusk, Honey & Slate), all shipped in V1; SCSS custom property tokens, selected per account +- **Typography:** Nunito variable weight; type scale 11–28px +- **Sticker canvas:** Snapchat-style interaction (tap-to-place, drag-to-move, pinch-to-resize, tap-× to delete); state stored as `{ id, type, x, y, scale, rotation }`; re-editable at any time; SVG sticker assets in 5 categories +- **Crop editor:** Instagram-style crop-first funnel; device aspect ratio border always visible in crop UI +- **Bottom sheet pattern:** Shared pattern for DevicePicker, StickerTray, ShareSheet; slides up 250ms ease-out, handle pill, tap-outside dismisses +- **Quiet completion:** No success modals; `BaseToast` (2.5s, no dismiss required) for all completions +- **Accessibility:** axe-core in dev; VoiceOver (iOS/macOS) screen reader testing; `aria-live="polite"` on toast; focus managed on route change and sheet open/close; keyboard fallback for all canvas actions +- **Email public flows:** Approve/decline email pages are Symfony Twig — no Vue, no JS required; must work in Gmail/Apple Mail/Outlook with images disabled ### FR Coverage Map -_To be completed in Step 3 (epic design)_ +FR1: Epic 1 — User registration +FR2: Epic 1 — User login +FR3: Epic 6 — Super admin cross-tenant user/device/image management +FR4: Epic 2 — Register device via provisioning setup flow +FR5: Epic 2 — Assign device name +FR6: Epic 2 — Configure display orientation +FR7: Epic 2 — Configure rotation frequency +FR8: Epic 2 — Configure uniqueness window +FR9: Epic 2 — Device ownership transfer on re-provisioning +FR10: Epic 2 / Epic 6 — Admin device view/manage (basic in E2, full cross-tenant in E6) +FR11: Epic 3 — Image upload +FR12: Epic 3 — Library filtered view (Uploaded vs. Shared) +FR13: Epic 3 — Soft-delete image +FR14: Epic 5 — Shared image appears as reference in recipient's library +FR15: Epic 6 — Global pre-loaded image pool management +FR16: Epic 4 — Per-device image approval +FR17: Epic 5 — Share image to another user +FR18: Epic 5 — Email share with approve link + device-selection page +FR19: Epic 5 — Approval link works from any email client, no login +FR20: Epic 4 — Approved images enter rotation pool +FR21: Epic 4 — Approve all images in a collection +FR22: Epic 5 — Request hard delete (enters admin queue) +FR23: Epic 5 — Confirmation email when hard delete is fulfilled +FR24: Epic 2 — Reset button triggers provisioning mode +FR25: Epic 2 — Provisioning QR code on e-ink +FR26: Epic 2 — Captive portal WiFi credential entry +FR27: Epic 2 — Success QR → /setup/{mac} page +FR28: Epic 2 — Failure indicator, AP re-activation, provisioning QR retry +FR29: Epic 2 — Register/login from device setup page +FR30: Epic 4 — Scheduled image rotation per device +FR31: Epic 4 — Uniqueness window tracking per device +FR32: Epic 4 — Uniqueness window capped at available image count +FR33: Epic 3 — Pre-render images at upload/approval time +FR34: Epic 4 — Device pulls pre-rendered image on scheduled cycle +FR35: Epic 4 — Persistent image display with no power draw between cycles +FR36: Epic 4 — Last image persists through power loss and WiFi outages +FR37: Epic 4 — Display only updates after confirmed complete transfer +FR38: Epic 4 — Yellow border on sync failure +FR39: Epic 4 — Red border on WiFi unavailable +FR40: Epic 6 — Admin hard-delete request queue +FR41: Epic 6 — Admin force hard delete +FR42: Epic 6 — Device ownership transfer audit log +FR43: Epic 6 — Scheduled hard-delete of orphaned soft-deleted images +FR44: Epic 6 — Soft-deleted images with active approvals retained ## Epic List -_To be completed in Step 2_ +### Epic 1: Project Foundation & User Authentication +Users can register, log in, and access a working application. The project scaffold (Symfony + Vue SPA + DDEV + CI) is in place with the theme system and base component library. +**FRs covered:** FR1, FR2 +**Also covers:** Symfony scaffold, Vue SPA (Vite + TypeScript strict), DDEV setup, Gitea CI, 6-theme SCSS token system, base components (BaseButton, BaseInput, BaseBottomSheet, BaseCard, BaseChip, BaseToast, BottomNav), VPS domain + Nginx config + +### Epic 2: Device Provisioning & Setup +A frame can be provisioned via two-phase QR flow, linked to an account, named, and configured. After this epic the frame is online and ready to display images. +**FRs covered:** FR4, FR5, FR6, FR7, FR8, FR9, FR24, FR25, FR26, FR27, FR28, FR29 + +### Epic 3: Image Library, Upload & Editing +Users can upload photos, crop them to the device's aspect ratio, add stickers, manage their library, and soft-delete images. The pre-rendering pipeline processes images at upload time. +**FRs covered:** FR11, FR12, FR13, FR33 +**Also covers:** CropEditor, StickerCanvas, StickerTray, PhotoThumb, DevicePicker components; sticker state persistence; library grid + search + +### Epic 4: Device Image Rotation & Display +Approved images enter the rotation pool and appear on the physical frame. The device pull endpoint serves pre-rendered assets; the rotation engine advances the cycle on schedule. Covers all firmware display and status behavior. +**FRs covered:** FR16, FR20, FR21, FR30, FR31, FR32, FR34, FR35, FR36, FR37, FR38, FR39 +**Also covers:** FrameCard component with all status states; HomeView with device-centric layout + +### Epic 5: Family Sharing & Email Approval +Users can share images with other users who can approve via email (no login required) or in-app. Shared images appear in the recipient's library. +**FRs covered:** FR14, FR17, FR18, FR19, FR22, FR23 +**Also covers:** ShareSheet, ApproveCard components; email template; Twig approve/decline public pages; token system + +### Epic 6: Admin & Moderation +Super admin manages the platform: cross-tenant user/device/image management, global image pool, delete request queue, force hard delete, device audit log, scheduled cleanup. +**FRs covered:** FR3, FR10, FR15, FR40, FR41, FR42, FR43, FR44 + +## Epic 1: Project Foundation & User Authentication + +Users can register, log in, and access a working application. The project scaffold (Symfony + Vue SPA + DDEV + CI) is in place with the theme system and base component library. + +### Story 1.1: Backend Infrastructure & Project Scaffold + +As a developer, +I want the Symfony application scaffold, DDEV environment, and VPS configured, +So that I have a working, deployable foundation to build on. + +**Acceptance Criteria:** + +**Given** a machine with DDEV installed +**When** the developer runs the setup commands +**Then** `ddev start` launches PHP 8.4, Nginx-FPM, PostgreSQL 16, and Imagick +**And** Symfony 8.0 scaffold is initialized via `symfony new pictureframe --webapp` +**And** `symfony/stimulus-bundle`, `symfony/ux-turbo`, and AssetMapper are removed from the project +**And** `symfony/messenger` with Doctrine transport and `symfony/scheduler` are installed +**And** `storage/images/` exists and is gitignored; `STORAGE_PATH` env var is set +**And** `phpunit.xml.dist` is configured and `php bin/console` runs without errors +**And** a Gitea CI workflow at `.gitea/workflows/ci.yml` runs `composer install` + `php bin/phpunit` on push + +**Given** the VPS and DNS are available +**When** the developer configures Nginx and requests a TLS certificate +**Then** `https://pictureframe.edholm.me` resolves and returns an HTTPS response +**And** the domain is established and ready to be baked into firmware build constants + +### Story 1.2: Vue SPA Scaffold & Base Component Library + +As a developer, +I want the Vue 3 SPA integrated with Symfony and a base component library in place, +So that all subsequent authenticated UI work builds on a consistent, accessible foundation. + +**Acceptance Criteria:** + +**Given** the Symfony scaffold is in place +**When** the Vue SPA is initialized in `frontend/` and Vite is configured +**Then** `vite.config.ts` outputs to `public/build/` and `npm run build` succeeds without errors +**And** TypeScript strict mode is enabled; `frontend/src/types/` directory exists with stub type files +**And** Vue Router and Pinia are configured; `frontend/src/router/index.ts` and `frontend/src/stores/` exist +**And** `SpaController` in Symfony serves `public/build/index.html` as a catch-all for authenticated routes +**And** unauthenticated requests to authenticated routes redirect to `/login` + +**Given** the SPA scaffold is in place +**When** base components are implemented +**Then** `BaseButton` (primary, secondary, ghost, destructive, icon-pill variants), `BaseInput` (floating label, error state), `BaseBottomSheet` (slide-up, handle pill, tap-outside dismiss), `BaseCard`, `BaseChip`, `BaseToast` (2.5s auto-dismiss, `aria-live="polite"`), and `BottomNav` (4-tab: Home / Library / Shared / Settings) all render correctly +**And** all components use scoped SCSS with SCSS custom property tokens +**And** all interactive elements have minimum 44px touch targets +**And** focus management is wired: sheet open moves focus to first element; sheet close returns focus to trigger + +### Story 1.3: User Registration + +As a visitor, +I want to create an account with my email and password, +So that I can access the pictureFrame app. + +**Acceptance Criteria:** + +**Given** a visitor navigates to `/register` +**When** they submit a valid email and password +**Then** an account is created with the password stored as a bcrypt hash +**And** the user is logged in automatically and redirected to the home screen (Vue SPA) + +**Given** a visitor submits a registration form +**When** the email address already exists in the system +**Then** an inline error displays below the email field: "An account with this email already exists" +**And** no account is created + +**Given** a visitor submits a registration form +**When** the password does not meet the minimum length requirement +**Then** an inline error displays below the password field describing the requirement +**And** validation fires on blur, not on keystroke + +### Story 1.4: User Login + +As a registered user, +I want to log in to my account, +So that I can access my frames and photo library. + +**Acceptance Criteria:** + +**Given** a registered user navigates to `/login` +**When** they enter their correct email and password and submit +**Then** they are authenticated and redirected to the home screen (Vue SPA) +**And** a `remember_me` session cookie is set to persist the session across browser restarts + +**Given** a user is on the login screen +**When** they enter an incorrect email or password +**Then** an inline error displays: "Incorrect email or password" +**And** no information is disclosed about whether the email exists + +**Given** a logged-in user taps Logout in Settings +**When** the logout action completes +**Then** the session is invalidated server-side +**And** the user is redirected to `/login` + +### Story 1.5: Theme Selection & Persistence + +As a logged-in user, +I want to choose my preferred visual theme, +So that the app reflects my personal style. + +**Acceptance Criteria:** + +**Given** a logged-in user navigates to Settings +**When** they select one of the 6 available themes +**Then** the selected theme applies immediately across the entire app without a page reload +**And** the theme is saved to the user's account on the server + +**Given** a user has previously selected a theme +**When** they reload the app or log in on a different browser +**Then** their previously selected theme is applied on load + +**Given** the theme system is implemented +**When** any of the 6 themes (Warm Craft, Playful Pop, Sage & Cream, Dusty Mauve, Ocean Dusk, Honey & Slate) is active +**Then** all text/background combinations meet WCAG AA 4.5:1 contrast ratio +**And** the SCSS custom properties (`--color-primary`, `--color-surface`, `--color-text`, etc.) are updated on the `` element + +## Epic 2: Device Provisioning & Setup + +A frame can be provisioned via two-phase QR flow, linked to an account, named, and configured. After this epic the frame is online and ready to display images. + +### Story 2.1: Firmware Phase 1 — AP Mode & Captive Portal + +As a frame recipient, +I want to connect my new frame to WiFi by scanning a QR code, +So that the frame can reach the internet without any technical knowledge. + +**Acceptance Criteria:** + +**Given** the frame is powered on for the first time (or after a 5-second button hold) +**When** it enters provisioning mode +**Then** the e-ink display shows a QR code encoding the AP SSID (`PictureFrame-{mac_suffix}`) +**And** the device broadcasts a WiFi access point with that SSID + +**Given** the user scans the QR code and joins the AP +**When** their phone opens the captive portal +**Then** a simple page prompts for home WiFi SSID and password only — no account, no server call +**And** tapping Connect attempts to join the provided network + +**Given** the home WiFi credentials are submitted +**When** the ESP32 successfully connects in STA mode +**Then** the e-ink display shows a success message and a new QR code encoding `https://pictureframe.edholm.me/setup/{mac}` +**And** the AP is deactivated + +**Given** the home WiFi credentials are submitted +**When** the ESP32 cannot connect to the provided network +**Then** the e-ink display fills red +**And** the AP reactivates automatically +**And** the provisioning QR code redisplays — no user action required to retry (FR28) + +### Story 2.2: Device Setup Page & Account Linking + +As a user who has connected their frame to WiFi, +I want to link the frame to my account by scanning the setup QR code, +So that the frame is registered to me and ready to display photos. + +**Acceptance Criteria:** + +**Given** the user scans the Phase 2 QR code +**When** their browser opens `/setup/{mac}` +**Then** the page renders as a Symfony Twig page (no Vue dependency, works with JS disabled) +**And** the page shows registration and login options + +**Given** a new user completes registration on the setup page +**When** the form is submitted successfully +**Then** their account is created and they are logged in +**And** the device MAC is linked to their account (FR4) +**And** they are redirected to the device naming step + +**Given** an existing user logs in on the setup page +**When** authentication succeeds +**Then** the device MAC is linked to their account +**And** they are redirected to the device naming step + +**Given** the device MAC is already linked to another account +**When** a new user completes setup +**Then** the MAC→account mapping is atomically updated to the new account +**And** the prior image history for that device is purged (FR9) + +### Story 2.3: Device Naming & Initial Configuration + +As a user who has linked a frame, +I want to name my frame and set its display preferences, +So that I can tell my frames apart and control how photos rotate. + +**Acceptance Criteria:** + +**Given** a user has just linked a device +**When** they complete the device naming step +**Then** they can assign a human-readable name (e.g. "Margaret's Frame") to the device (FR5) +**And** they can set display orientation: landscape or portrait (FR6) +**And** they can set rotation frequency (e.g. every 6 hours, daily, weekly) (FR7) +**And** they can set the uniqueness window — number of cycles before an image repeats (FR8) +**And** the device record is saved and the frame reboots into normal operation + +**Given** a user saves device settings +**When** they later navigate to the device detail screen in the app +**Then** they can edit the name, orientation, frequency, and uniqueness window at any time +**And** changes take effect on the next rotation cycle + +### Story 2.4: Home Screen Device List (FrameCard) + +As a logged-in user, +I want to see my frames on the home screen with a clear action to add photos, +So that managing my frames feels immediate and obvious. + +**Acceptance Criteria:** + +**Given** a logged-in user with one linked device navigates to Home +**When** the home screen loads +**Then** a large `FrameCard` shows the device name, current photo preview (or empty state if none), and a prominent "+ Add Photo" button + +**Given** a logged-in user has two or more linked devices +**When** the home screen loads +**Then** each device is shown as a compact stacked `FrameCard` with its name, photo count, and "+ Add" pill button + +**Given** a logged-in user has no linked devices +**When** the home screen loads +**Then** a single card-shaped empty state displays: "Set up your first frame" with a QR setup CTA + +**Given** a user taps "+ Add Photo" on a specific FrameCard +**When** the upload funnel opens +**Then** that device is pre-selected throughout the funnel + +**Given** a device has a known status state +**When** the FrameCard renders +**Then** an `offline` state shows a red border + "Offline" label (no WiFi) +**And** a `sync-fail` state shows a yellow border + "Sync issue" label (WiFi up, server unreachable) +**And** status is communicated by both color and text — never color alone (WCAG requirement) +**And** in Epic 1–3 these states render correctly as placeholders; Epic 4 wires live device status data + +### Story 2.5: Reset Button & Re-Provisioning + +As a user who needs to reset their frame, +I want to hold the reset button to return to provisioning mode, +So that I can reconfigure WiFi or transfer the frame to someone else. + +**Acceptance Criteria:** + +**Given** the frame is in normal operating mode +**When** the physical reset button is held for 5 seconds (FR24) +**Then** the device returns to Phase 1 provisioning mode +**And** the e-ink display shows the AP provisioning QR code +**And** any previously stored WiFi credentials are cleared + +**Given** a device in provisioning mode is claimed by a new account +**When** the setup is completed by a different user +**Then** the server atomically purges the prior image history and links the device to the new owner (FR9) +**And** the previous owner's images are no longer served to that device + +## Epic 3: Image Library, Upload & Editing + +Users can upload photos, crop them to the device's aspect ratio, add stickers, manage their library, and soft-delete images. The pre-rendering pipeline processes images at upload time. + +### Story 3.1: Image Upload & Pre-Rendering Pipeline + +As a user, +I want to upload a photo to my library, +So that I have images available to add to my frames. + +**Acceptance Criteria:** + +**Given** a logged-in user taps "+ Add Photo" from any context +**When** the system photo picker opens and they select a photo +**Then** the photo is uploaded to the server and stored as an original at `storage/images/{id}/original.{ext}` +**And** `ProcessImageMessage` is dispatched via Symfony Messenger for each of the user's device models and orientations +**And** the image appears in the user's library with `RenderStatus::Pending` while processing + +**Given** `ProcessImageMessageHandler` consumes the message +**When** Imagick resizes and dithers the image to the device's resolution and 6-color palette +**Then** the pre-rendered binary asset is stored at `storage/images/{id}/{device_model}_{orientation}.bin` +**And** `RenderedAsset.status` is set to `RenderStatus::Ready` + +**Given** a user uploads a photo and has no linked devices +**When** the upload completes +**Then** no `ProcessImageMessage` is dispatched — rendering is deferred until the image is approved for a specific device +**And** the image appears in the library with no render status indicator + +**Given** image processing fails +**When** the handler throws an exception after max retries (1 retry) +**Then** `RenderedAsset.status` is set to `RenderStatus::Failed` +**And** the failure is visible to the super admin + +### Story 3.2: Crop Editor + +As a user uploading a photo, +I want to crop my photo to fit my frame's shape, +So that the image fills the display without awkward letterboxing. + +**Acceptance Criteria:** + +**Given** a user has selected a photo and the crop screen opens +**When** the crop UI renders +**Then** the device's aspect ratio (800×480 landscape or 480×800 portrait) is shown as a visible frame border +**And** the destination device's name ("Margaret's Frame") is shown in the corner if launched from a specific FrameCard +**And** the user can pinch and drag to fit the photo within the frame boundary + +**Given** a user adjusts the crop +**When** they tap Next +**Then** the crop parameters are stored and the sticker screen opens +**And** the cropped region is preserved for the final render + +**Given** a user's device supports both orientations +**When** they tap the orientation toggle on the crop screen +**Then** the frame border switches between landscape and portrait aspect ratios +**And** the crop is reset to fit the new orientation + +### Story 3.3: Sticker Canvas — Interaction + +As a user editing a photo, +I want to place fun sticker overlays on my image and manipulate them with touch gestures, +So that I can personalize photos before adding them to a frame. + +**Acceptance Criteria:** + +**Given** a user is on the sticker screen after cropping +**When** they tap the sticker tray icon +**Then** a bottom sheet slides up showing sticker categories (Seasonal · Holidays · Fun · Family · Nature) +**And** stickers scroll horizontally within each category +**And** tapping the canvas or swiping the sheet down dismisses the tray + +**Given** a user taps a sticker from the tray +**When** the sticker is placed +**Then** it appears centered on the Konva.js canvas +**And** the user can drag it to reposition, pinch to resize, and tap × to delete it +**And** multiple stickers can be placed simultaneously + +**Given** a user is on a desktop browser +**When** they interact with the sticker canvas +**Then** drag-to-move is functional with a mouse +**And** scroll-to-resize is available as a fallback for pinch-to-resize +**And** a visible × button on each sticker serves as keyboard-accessible delete + +### Story 3.4: Sticker Canvas — State Persistence & Re-editing + +As a user who has placed stickers on a photo, +I want my sticker composition saved and re-editable at any time, +So that I can refine my edits without losing work. + +**Acceptance Criteria:** + +**Given** a user places stickers and proceeds past the sticker step +**When** the upload funnel completes +**Then** sticker state (`{ id, type, x, y, scale, rotation }`) is persisted to the database separately from the rendered output +**And** the `PhotoThumb` for this image shows a sticker badge indicator in the library + +**Given** a user returns to a previously stickered photo in the library +**When** they open the edit view +**Then** all previously placed stickers are restored to their saved positions, scales, and rotations on the Konva canvas +**And** the user can add, move, resize, or delete existing stickers + +**Given** a user edits and saves a stickered photo +**When** the re-edit is confirmed +**Then** `ProcessImageMessage` is dispatched to re-render the updated composition for all approved devices +**And** the new rendered asset replaces the previous one in `storage/images/{id}/` + +### Story 3.5: Add to Frame (Device Picker) + +As a user who has finished editing a photo, +I want to choose which frame to add it to, +So that the photo enters rotation on the right device. + +**Acceptance Criteria:** + +**Given** a user taps "Add to Frame" at the end of the upload funnel +**When** the DevicePicker bottom sheet opens +**Then** all of the user's linked devices are shown by name with a thumbnail of their current photo +**And** the device the user launched from (if any) is pre-selected + +**Given** a user selects one or more devices and taps Done +**When** the action completes +**Then** the image is approved for those devices and enters the rotation pool (status: ready) +**And** a `BaseToast` briefly confirms: "Photo added to [Frame Name]" +**And** the funnel closes and the user is returned to the home screen + +**Given** a user has no linked devices +**When** the DevicePicker opens +**Then** an empty state shows: "You don't have any frames yet" with a link to provisioning + +### Story 3.6: Image Library View + +As a logged-in user, +I want to browse and manage my photo library, +So that I can see what I've uploaded, find specific photos, and remove ones I no longer want. + +**Acceptance Criteria:** + +**Given** a user navigates to the Library tab +**When** the library loads +**Then** photos are displayed in a responsive grid (`PhotoThumb` components: 2 cols mobile, 3 tablet, 4 desktop) +**And** photos with saved sticker compositions show a badge indicator +**And** the library is filterable by tab: All / Mine / Shared + +**Given** a user types in the library search field +**When** their query matches photo metadata +**Then** the grid filters in real time (300ms debounce) +**And** a "× Clear" affordance appears inline in the search field + +**Given** a user soft-deletes a photo (FR13) +**When** they confirm the deletion +**Then** the photo is immediately removed from their library view +**And** `Image.deleted_at` is set; the image is excluded from `findActive*` queries +**And** if the image has active approvals on any device, those approvals are retained and the image remains in rotation on those devices until the last approval is removed (FR44) + +## Epic 4: Device Image Rotation & Display + +Approved images enter the rotation pool and appear on the physical frame. The device pull endpoint serves pre-rendered assets; the rotation engine advances the cycle on schedule. Covers all firmware display and status behavior. + +### Story 4.1: Per-Device Image Approval + +As a logged-in user, +I want to approve or decline images for a specific frame, +So that I control which photos appear in rotation on each device. + +**Acceptance Criteria:** + +**Given** a user has images in their library +**When** they open an image and tap "Add to Frame" +**Then** the DevicePicker bottom sheet shows their linked devices by name +**And** selecting a device and tapping Done approves the image for that device (FR16) +**And** the image enters the rotation pool for that device with `RenderStatus::Ready` + +**Given** a user wants to remove an image from a device +**When** they open the image detail and select "Remove from [Frame Name]" +**Then** the approval is revoked and the image is removed from that device's rotation pool +**And** if the image has no remaining approvals and is soft-deleted, it becomes eligible for cleanup + +**Given** a user taps "Approve All" on a collection (FR21) +**When** the action completes +**Then** all images in the collection are approved for the selected device in a single operation +**And** `ProcessImageMessage` is dispatched for any images not yet rendered for that device model/orientation + +### Story 4.2: Rotation Engine & Uniqueness Window + +As a frame owner, +I want my frame to automatically cycle through approved photos on a schedule, +So that the frame stays fresh without any ongoing effort from me. + +**Acceptance Criteria:** + +**Given** a device has approved images with `RenderStatus::Ready` +**When** the `RotationSchedule` fires at the device's configured interval (FR30) +**Then** `RotationService` selects the next image that has not appeared within the uniqueness window (FR31) +**And** the device's `current_image` pointer is advanced to the selected image + +**Given** the uniqueness window is larger than the count of available approved images +**When** the rotation engine selects the next image +**Then** the window is treated as equal to the available image count (FR32) +**And** rotation continues without error + +**Given** a device has no approved images with `RenderStatus::Ready` +**When** the rotation engine fires +**Then** the `current_image` pointer is unchanged +**And** no error state is triggered + +### Story 4.3: Device Pull Endpoint + +As an ESP32 device, +I want to pull my next pre-rendered image from the server on each scheduled cycle, +So that the frame displays a fresh photo without performing any image processing on-device. + +**Acceptance Criteria:** + +**Given** the device sends `GET /api/device/{mac}/image` +**When** the MAC is registered and a `RenderStatus::Ready` image is available +**Then** the server returns `200 OK` with the binary asset as `application/octet-stream` (FR34) +**And** the response is served within 10 seconds on typical home broadband (NFR2) + +**Given** the device sends `GET /api/device/{mac}/image` +**When** the MAC is registered but no `RenderStatus::Ready` image is currently due +**Then** the server returns `204 No Content` — never `404` (FR34, architecture critical rule) + +**Given** the device sends `GET /api/device/{mac}/image` +**When** the MAC is not registered in the system +**Then** the server returns `404 Not Found` +**And** the firmware treats this as a permanent error state (device not configured) + +### Story 4.4: Firmware Image Display & Atomic Transfer + +As a frame recipient, +I want photos to appear reliably on my frame and never show a blank screen, +So that the frame always looks good regardless of network conditions. + +**Acceptance Criteria:** + +**Given** the device receives a `200` response with a binary asset +**When** the full transfer completes and is confirmed +**Then** the e-ink display refreshes to show the new image (FR37) +**And** the new image is written to persistent storage as the last-known-good image + +**Given** the device is mid-transfer and loses power or WiFi +**When** power or connectivity is restored +**Then** the previous successfully transferred image remains on the display (FR36) +**And** the partial transfer is discarded — no corrupted display state + +**Given** the device has never successfully received an image +**When** it powers on +**Then** the display shows a placeholder or blank e-ink state — a blank screen is acceptable only in this initial state (FR35, NFR11) + +### Story 4.5: Border Status Indicators & Offline Recovery + +As a frame recipient, +I want my frame to show me when something is wrong, +So that I know whether to troubleshoot or just wait. + +**Acceptance Criteria:** + +**Given** the device wakes for a scheduled pull cycle +**When** WiFi is unavailable (no network connectivity) +**Then** the e-ink display renders a red border around the current image (FR39) +**And** the device returns to deep sleep and retries on the next scheduled cycle + +**Given** the device wakes for a scheduled pull cycle +**When** WiFi is connected but the server is unreachable or returns an error +**Then** the e-ink display renders a yellow border around the current image (FR38) +**And** the device returns to deep sleep and retries on the next scheduled cycle + +**Given** a device previously showing a red or yellow border +**When** the next scheduled pull succeeds and a `200` or `204` is returned +**Then** the border is removed from the display +**And** if `200`, the new image is displayed; if `204`, the current image remains (no blank screen) + +**Given** a device's pull endpoint returns an error or times out +**When** the device's status is reported back to the server (or inferred from last-seen timestamp) +**Then** the `FrameCard` in the Vue app shows the `sync-fail` state (yellow border + "Sync issue" label) +**And** when the device successfully pulls again, the `FrameCard` returns to its normal state +**And** the `offline` state (red border + "Offline") is shown when the device has not been seen beyond a configurable threshold + +### Story 4.6: Scheduler Setup — Rotation & Cleanup + +As a developer, +I want the Symfony Scheduler configured with rotation and cleanup schedules, +So that image rotation and orphaned asset cleanup run automatically without manual intervention. + +**Acceptance Criteria:** + +**Given** the Symfony Scheduler component is installed +**When** the developer runs `php bin/console debug:scheduler` +**Then** `RotationSchedule` appears registered, firing at the per-device configured interval (FR30) +**And** `ImageCleanupSchedule` appears registered, firing on its configured periodic interval (FR43) + +**Given** the worker process is running (`php bin/console messenger:consume`) +**When** a device's rotation interval elapses +**Then** `RotationService` advances the `current_image` pointer for that device +**And** the scheduler does not fire rotation for devices with no ready images — no error is thrown + +**Given** the cleanup schedule fires +**When** it finds soft-deleted images with no remaining approvals +**Then** it hard-deletes those images and their storage assets (FR43) +**And** images with at least one active approval are skipped (FR44) + +## Epic 5: Family Sharing & Email Approval + +Users can share images with other users who can approve via email (no login required) or in-app. Shared images appear in the recipient's library. + +### Story 5.1: Share an Image to Another User + +As a logged-in user, +I want to share a photo from my library with another person, +So that they can add it to their frame. + +**Acceptance Criteria:** + +**Given** a user is viewing a photo in their library +**When** they tap Share +**Then** the `ShareSheet` bottom sheet opens with a search field auto-focused +**And** the field searches connected family members by name and email simultaneously +**And** matching results appear as tappable rows with avatar initial and name + +**Given** a user selects a recipient and taps Send +**When** the share action completes +**Then** the server creates a `Token` of type `ShareApprove` and `ShareDecline` with a configurable TTL +**And** a share notification email is sent to the recipient containing the image preview and an Approve button (FR17) +**And** a `BaseToast` confirms: "Photo shared with [Name]" + +**Given** a user attempts to share a photo +**When** the recipient's email is not in the system +**Then** the user can enter an email address directly to invite them +**And** the share email is sent to that address with a registration prompt alongside the approve link + +### Story 5.2: Email Approve Flow (No Login Required) + +As a photo recipient, +I want to approve a shared photo by tapping a link in my email, +So that I can add it to my frame without needing to log in or install anything. + +**Acceptance Criteria:** + +**Given** a recipient receives a share email +**When** they tap the Approve button in any email client +**Then** their browser opens `/token/{uuid}/approve` — a Symfony Twig page with no Vue, no JS required (FR19) +**And** the page renders correctly with images disabled and is screen reader accessible (WCAG AA) + +**Given** the recipient is on the device-selection page +**When** the token is valid and unused +**Then** their linked devices are shown by human name — no technical identifiers (FR18) +**And** they can select one or more devices and tap Done without logging in + +**Given** the recipient taps Done on the device-selection page +**When** the token is consumed +**Then** the token's `used_at` is set, marking it as single-use +**And** the image enters the approved rotation pool for the selected device(s) (FR20) +**And** `ProcessImageMessage` is dispatched for any device models not yet rendered +**And** a confirmation message is shown: "Photo added to [Frame Name]" + +**Given** a recipient taps the Approve link after the token has expired or already been used +**When** the page loads +**Then** a friendly message explains the link is no longer valid +**And** no approval action is taken + +### Story 5.3: Email Decline Flow + +As a photo recipient, +I want to decline a shared photo from my email, +So that I can keep unwanted photos off my frame without logging in. + +**Acceptance Criteria:** + +**Given** a recipient taps Decline in the share email +**When** their browser opens `/token/{uuid}/decline` +**Then** the page renders as a Symfony Twig page with no login required +**And** the token is consumed and `used_at` is set + +**Given** a user declines a photo +**When** the decline is processed +**Then** the image is not added to any device +**And** the image does not appear in the recipient's library + +**Given** a token has already been used or expired +**When** the decline link is tapped +**Then** a friendly message explains the link is no longer valid + +### Story 5.4: In-App Approval & Shared Library Tab + +As a logged-in user, +I want to approve or decline shared photos from within the app, +So that I can manage incoming shares without checking my email. + +**Acceptance Criteria:** + +**Given** a user has pending shared photos +**When** they navigate to the Library → Shared tab +**Then** each pending photo is shown as an `ApproveCard` with Approve and Decline actions inline +**And** the Shared tab in `BottomNav` shows a numeric badge for the count of pending approvals + +**Given** a user taps Approve on an `ApproveCard` +**When** the DevicePicker bottom sheet opens and they select a device +**Then** the image enters the approved rotation pool — identical outcome to the email approve flow (FR16, FR20) +**And** the `ApproveCard` is removed from the Shared tab immediately + +**Given** a user taps Decline on an `ApproveCard` +**When** they confirm the inline confirmation +**Then** the image is removed from the Shared tab +**And** it does not appear on any device + +**Given** a shared photo is approved via the email link +**When** the user later opens the Shared tab +**Then** the approved photo no longer appears as pending — the two approval paths are in sync + +### Story 5.5: Shared Image in Recipient's Library + +As a user who has received and approved a shared photo, +I want to see it in my library, +So that I can manage it like my own photos. + +**Acceptance Criteria:** + +**Given** a user approves a shared image (via email or in-app) +**When** they view their library +**Then** the image appears in the Shared tab filtered view (FR12, FR14) +**And** it is stored as a reference to the original — not a copy +**And** it shows the sharer's name as the source + +**Given** a user views a shared image in their library +**When** they open the image detail +**Then** they can add it to additional devices, re-edit stickers, or remove it from a device +**And** they can request a hard delete if they want it permanently removed (FR22) + +### Story 5.6: Hard Delete Request + +As a user, +I want to request permanent deletion of one of my photos, +So that it is fully removed from the server and all devices. + +**Acceptance Criteria:** + +**Given** a user opens an image detail in their library +**When** they tap "Request permanent deletion" +**Then** an inline confirmation asks them to confirm the irreversible action +**And** on confirmation, a hard-delete request is created and enters the super admin review queue (FR22) +**And** a `BaseToast` confirms: "Deletion request submitted" + +**Given** a super admin fulfils the hard delete request +**When** the deletion is processed +**Then** the system sends a confirmation email to the requesting user (FR23) +**And** the image is removed from all device rotation pools and from storage + +**Given** a hard delete request exists for an image +**When** the image still has active approvals on devices +**Then** the admin is shown which devices still have the image approved before confirming +**And** the force delete removes all approvals and the file from storage (FR41) + +## Epic 6: Admin & Moderation + +Super admin manages the platform: cross-tenant user/device/image management, global image pool, delete request queue, force hard delete, device audit log, scheduled cleanup. + +### Story 6.1: Super Admin User & Device Management + +As a super admin, +I want to view and manage all user accounts and devices across the system, +So that I can support users and maintain the health of the platform. + +**Acceptance Criteria:** + +**Given** the super admin navigates to the admin panel +**When** the Users section loads +**Then** all registered user accounts are listed with email, account creation date, and device count +**And** the super admin can view, edit, and delete any user account (FR3) + +**Given** the super admin views a user's account +**When** they navigate to that user's devices +**Then** all devices linked to that account are shown with name, MAC address, orientation, and rotation config +**And** the super admin can rename, reconfigure, or transfer any device to another account (FR10) + +**Given** the super admin transfers a device to a new account +**When** the transfer is confirmed +**Then** the device's MAC→account mapping is updated atomically +**And** the device's image history is purged +**And** the transfer is recorded in the device ownership audit log (FR42) + +### Story 6.2: Device Ownership Transfer Audit Log + +As a super admin, +I want to view a history of device ownership transfers, +So that I can track which accounts have owned each device over time. + +**Acceptance Criteria:** + +**Given** the super admin navigates to a device's detail page +**When** they open the audit log +**Then** all ownership transfer events are listed with timestamp, previous account, and new account (FR42) + +**Given** a device is re-provisioned by a user via the physical reset button +**When** the new account claims the device +**Then** a transfer event is automatically recorded in the audit log +**And** it is attributed to the physical reset action + +### Story 6.3: Global Pre-Loaded Image Pool + +As a super admin, +I want to manage a global pool of images available to all devices, +So that new frames have content to display before family members have uploaded photos. + +**Acceptance Criteria:** + +**Given** the super admin navigates to the Global Images section +**When** they upload an image +**Then** the image is stored in the global pool and pre-rendered for all active device models and orientations (FR15) +**And** `ProcessImageMessage` is dispatched for each device model/orientation combination + +**Given** the super admin removes an image from the global pool +**When** the removal is confirmed +**Then** the image is removed from the global pool and from all device rotation pools that sourced it from the global pool +**And** pre-rendered assets are cleaned up from storage + +**Given** a device has no user-approved images in its rotation pool +**When** the rotation engine selects the next image +**Then** global pool images are eligible as fallback content + +### Story 6.4: Hard Delete Request Queue + +As a super admin, +I want to review and action user-submitted hard delete requests, +So that I can permanently remove images users no longer want on the server. + +**Acceptance Criteria:** + +**Given** the super admin navigates to the Delete Requests section +**When** the queue loads +**Then** all pending hard delete requests are listed with the requesting user, image preview, submission date, and current approval status across devices (FR40) + +**Given** the super admin reviews a request and clicks Fulfil +**When** there are no remaining active approvals on any device +**Then** the image binary assets and original are deleted from `storage/images/{id}/` +**And** the `Image` record is hard-deleted from the database +**And** a confirmation email is sent to the requesting user (FR23, FR41) + +**Given** the super admin reviews a request and clicks Fulfil +**When** the image still has active approvals on one or more devices +**Then** the admin is shown which devices still have the image approved +**And** confirming removes all approvals before hard-deleting (FR41) + +**Given** the super admin dismisses a request +**When** the dismissal is confirmed +**Then** the request is removed from the queue +**And** the image is not deleted — it remains in the user's library + +### Story 6.5: Scheduled Image Cleanup + +As the system, +I want to automatically hard-delete orphaned soft-deleted images, +So that no unused assets accumulate on disk without manual intervention. + +**Acceptance Criteria:** + +**Given** the `ImageCleanupSchedule` fires on its configured interval +**When** it finds images where `deleted_at IS NOT NULL` and no active approvals remain (FR43) +**Then** the image binary assets and originals are deleted from storage +**And** the `Image` record is hard-deleted from the database + +**Given** a soft-deleted image still has at least one active approval on a device +**When** the cleanup job runs +**Then** the image is not deleted — it is retained until the last approval is removed (FR44) + +**Given** the cleanup job encounters an error deleting a specific image +**When** the error occurs +**Then** the job logs the failure and continues processing remaining images +**And** the failed deletion is retried on the next scheduled run + +### Story 6.6: Super Admin Image Moderation + +As a super admin, +I want to view and force-delete any image across all accounts, +So that I can remove harmful or inappropriate content from the platform. + +**Acceptance Criteria:** + +**Given** the super admin navigates to the Images section +**When** they search or browse across all accounts +**Then** all images in the system are listed with owner, upload date, approval count, and render status (FR3) + +**Given** the super admin selects an image and clicks Force Hard Delete +**When** they confirm the action +**Then** all approvals for that image are revoked across all devices +**And** the image is removed from all rotation pools immediately +**And** the binary assets and original are deleted from storage (FR41) +**And** no confirmation email is sent for admin-initiated force deletes (only user-requested deletes trigger FR23)