21e9173508
6 epics, 34 stories, 44/44 FRs covered. Includes party mode review fixes: sticker canvas split into interaction + state persistence stories, zero-device upload edge case AC, FrameCard offline/sync-fail states, and scheduler setup story. All stories have Given/When/Then AC and no forward dependencies. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1040 lines
52 KiB
Markdown
1040 lines
52 KiB
Markdown
---
|
||
stepsCompleted: [1, 2, 3]
|
||
inputDocuments: ['prd.md', 'architecture.md', 'ux-design-specification.md']
|
||
workflowType: 'epics-and-stories'
|
||
project_name: 'pictureFrame'
|
||
user_name: 'Matt.edholm'
|
||
date: '2026-04-27'
|
||
---
|
||
|
||
# pictureFrame - Epic Breakdown
|
||
|
||
## Overview
|
||
|
||
This document provides the complete epic and story breakdown for pictureFrame, decomposing the requirements from the PRD and Architecture into implementable stories.
|
||
|
||
## Requirements Inventory
|
||
|
||
### Functional Requirements
|
||
|
||
FR1: Visitors can register a new account with an email address and password
|
||
FR2: Registered users can log in to their account
|
||
FR3: Super admin can view, edit, and delete any user account, device, or image across the system
|
||
|
||
FR4: Users can register a device to their account via the provisioning setup flow
|
||
FR5: Users can assign a name to each of their devices
|
||
FR6: Users can configure display orientation (landscape or portrait) per device
|
||
FR7: Users can configure the image rotation frequency per device
|
||
FR8: Users can configure the uniqueness window (number of cycles before an image can repeat) per device
|
||
FR9: When a device is re-provisioned to a new account, the system atomically purges the prior image history and transfers ownership
|
||
FR10: Super admin can view, rename, reconfigure, and transfer any device across all accounts
|
||
|
||
FR11: Users can upload photos to their personal image library
|
||
FR12: Users can view their library filtered by source: Uploaded vs. Shared
|
||
FR13: Users can soft-delete images from their library
|
||
FR14: When an image is shared to a user, it appears in their library as a reference (not a copy)
|
||
FR15: Super admin can add images to and remove images from a global pre-loaded image pool available to all devices
|
||
|
||
FR16: Users can approve or decline images for a specific device in their account
|
||
FR17: Users can share an image from their library to another user
|
||
FR18: When an image is shared, the recipient receives an email with the image and an approve action; clicking approve opens a device-selection page (no login required) where the recipient chooses which device(s) to add the image to
|
||
FR19: The approval link works from any email client without requiring account creation or login
|
||
FR20: Approved images enter the active rotation pool for the selected device(s)
|
||
FR21: Users can approve all images within a collection for a device in a single action
|
||
FR22: Users can request a full hard delete of their own image, which enters a super-admin review queue
|
||
FR23: System sends confirmation to the user when their hard-delete request is fulfilled
|
||
|
||
FR24: Device enters provisioning mode when the reset button is held for 5 seconds
|
||
FR25: In provisioning mode, device displays a scannable QR code for joining the provisioning access point
|
||
FR26: User can enter home WiFi credentials through a captive portal served by the device
|
||
FR27: On successful WiFi connection, device displays a QR code linking to the account setup page for that specific device
|
||
FR28: On failed WiFi connection, device displays a failure indicator, reactivates the AP, and redisplays the provisioning QR code for retry
|
||
FR29: Users can register a new account or log in to an existing account from the device setup page to link the device
|
||
|
||
FR30: System automatically advances to the next approved image on the configured schedule for each device
|
||
FR31: System tracks image display history per device to enforce the configured uniqueness window
|
||
FR32: When the uniqueness window exceeds the count of available approved images, the window is treated as equal to the available image count
|
||
FR33: System pre-renders images to display-ready format per device model and orientation at the time of upload or approval
|
||
FR34: Device pulls its next pre-rendered image from the server on each scheduled cycle
|
||
|
||
FR35: Device displays the current image persistently with no power draw between refresh cycles
|
||
FR36: Device retains and displays the last successfully transferred image through power loss and WiFi outages
|
||
FR37: Device only updates the display after a complete, confirmed image transfer
|
||
FR38: Device renders a yellow border when WiFi is connected but the server sync fails
|
||
FR39: Device renders a red border when WiFi connectivity is unavailable
|
||
|
||
FR40: Super admin can view a queue of user-submitted hard-delete requests and fulfill or dismiss them
|
||
FR41: Super admin can force an immediate hard delete of any image in the system
|
||
FR42: Super admin can view a device ownership transfer audit log
|
||
FR43: System automatically hard-deletes soft-deleted images that have no remaining approvals via a scheduled background process
|
||
FR44: Soft-deleted images with at least one active approval are retained until all approvals are removed
|
||
|
||
### NonFunctional Requirements
|
||
|
||
NFR1 (Performance): Image pre-rendering completes within 10 seconds of upload or approval trigger
|
||
NFR2 (Performance): Device image pull endpoint returns the pre-rendered binary asset within 10 seconds on typical home broadband
|
||
NFR3 (Performance): Web application pages load within 3 seconds on a standard broadband connection
|
||
NFR4 (Performance): Image rotation fires within ±5 minutes of the configured interval
|
||
|
||
NFR5 (Security): All device-to-server communication occurs over HTTPS
|
||
NFR6 (Security): Device image pull requests are authenticated by MAC address; server rejects requests from unregistered MACs
|
||
NFR7 (Security): Email inline approve/decline actions use single-use authorization links that expire after use or after a configurable TTL
|
||
NFR8 (Security): User accounts are isolated — a user cannot access another user's images or devices without an explicit sharing action
|
||
NFR9 (Security): Super admin access is restricted to a single designated account
|
||
NFR10 (Security): Device ownership transfer requires physical access to the reset button
|
||
|
||
NFR11 (Reliability): A device must never display a blank screen in normal operation — the last successfully transferred image persists indefinitely
|
||
NFR12 (Reliability): A display refresh only occurs after a complete, confirmed transfer; a mid-transfer interruption leaves the previous image on screen
|
||
NFR13 (Reliability): Soft-delete and scheduled cleanup run on a scheduled basis without manual intervention
|
||
NFR14 (Reliability): Breaking API changes are prohibited in V1
|
||
|
||
NFR15 (Accessibility): All primary user journeys complete successfully on iOS Safari (latest) and Android Chrome (latest)
|
||
NFR16 (Accessibility): Email sharing flows must work without requiring app installation or login on the recipient's device
|
||
|
||
### Additional Requirements
|
||
|
||
Architecture-derived technical requirements:
|
||
- **Starter template:** `symfony new pictureframe --webapp` — first implementation action
|
||
- **DDEV setup:** PHP 8.4, nginx-fpm, PostgreSQL 16 — mirror aqua-iq `.ddev/config.yaml`; add `docker-compose.imagick.yaml` for Imagick extension
|
||
- **Domain first:** `pictureframe.edholm.me` must be established and Nginx configured on VPS before firmware build constants are set
|
||
- **Imagick:** Must be installed in DDEV container and on VPS before image processing worker can run
|
||
- **Symfony Messenger:** Doctrine transport, `image_processing` queue, `max_retries: 1`
|
||
- **Symfony Scheduler:** Required for rotation engine and cleanup jobs
|
||
- **Storage:** `storage/images/` directory, `STORAGE_PATH` env var, relative paths in DB
|
||
- **Git / CI:** Repository at `git.edholm.me`; Gitea Actions CI workflow
|
||
- **Critical implementation rule:** Device pull endpoint returns 204 (no ready image) vs 404 (unknown MAC) — must never confuse the two
|
||
- **Token entity:** UUID PK, TokenType enum (ShareApprove, ShareDecline, HardDeleteConfirm), expires_at, used_at
|
||
- **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
|
||
|
||
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
|
||
|
||
### 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 `<html>` 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)
|