docs: add complete epics and stories breakdown
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>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
stepsCompleted: [1]
|
stepsCompleted: [1, 2, 3]
|
||||||
inputDocuments: ['prd.md', 'architecture.md']
|
inputDocuments: ['prd.md', 'architecture.md', 'ux-design-specification.md']
|
||||||
workflowType: 'epics-and-stories'
|
workflowType: 'epics-and-stories'
|
||||||
project_name: 'pictureFrame'
|
project_name: 'pictureFrame'
|
||||||
user_name: 'Matt.edholm'
|
user_name: 'Matt.edholm'
|
||||||
@@ -107,11 +107,933 @@ Architecture-derived technical requirements:
|
|||||||
- **Enums:** PHP backed enums for RenderStatus, TokenType, Orientation
|
- **Enums:** PHP backed enums for RenderStatus, TokenType, Orientation
|
||||||
- **Repository naming:** `findActive*` prefix on all soft-delete-aware methods
|
- **Repository naming:** `findActive*` prefix on all soft-delete-aware methods
|
||||||
- **No OTA firmware:** API contract is stable by design; breaking changes require physical reflash
|
- **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
|
### 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
|
## 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 `<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)
|
||||||
|
|||||||
Reference in New Issue
Block a user