Files
football2801 94dae685e2 docs: update architecture to reflect Vue 3 SPA frontend decision
Replace AssetMapper + Stimulus + Turbo with Vue 3 SPA (Vite,
TypeScript strict, SCSS modules, Konva.js). Authenticated app is
now a full SPA served by Symfony catch-all; public flows (provisioning,
email approve/decline) remain Symfony + Twig. Add JSON API controllers
for SPA, SpaController catch-all, updated directory structure, and
revised implementation sequence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 21:57:06 -04:00

625 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
stepsCompleted: [1, 2, 3, 4, 5, 6, 7, 8]
lastStep: 8
status: 'complete'
completedAt: '2026-04-27'
inputDocuments: ['prd.md']
workflowType: 'architecture'
project_name: 'pictureFrame'
user_name: 'Matt.edholm'
date: '2026-04-27'
---
# Architecture Decision Document
_This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together._
## Project Context Analysis
### Requirements Overview
**Functional Requirements:** 44 FRs across 8 domains — User & Account Management, Device Management, Image Library, Image Approval & Sharing, Device Provisioning, Image Rotation & Cycle Engine, Display & Status, Admin & Moderation.
**Non-Functional Requirements:**
- Image pre-rendering completes within 10 seconds of upload/approval trigger (async job target)
- Device image pull endpoint ≤10s on home broadband
- Web pages load ≤3s on standard broadband
- Image rotation ±5 minutes of configured interval
- HTTPS all device-server communication
- MAC-authenticated device endpoint
- Single-use authorization links with configurable TTL
- Scheduled cleanup without manual intervention
- Zero blank screens; last image persists through outages
**Scale & Complexity:**
- Primary domain: Full-stack web app with IoT device client
- Complexity level: Medium
- Deployment target: Single VPS, solo developer, personal-scale traffic
- Estimated architectural components: Web app (Symfony), Async image processing worker, Device API endpoint, Email/token service, Scheduler/cron runner, ESP32 firmware (separate codebase)
### Technical Constraints & Dependencies
- Stack: Symfony (PHP), PostgreSQL, Nginx-FPM, DDEV local dev — mirrors aqua-iq
- APP_BASE_URL is a firmware build constant — domain must be established before firmware development
- API contract is stable by design; breaking changes require physical reflash of all deployed devices
- No OTA firmware updates in V1
- Infrastructure patterns from ~/src/aqua-iq (DDEV, server, SSH)
### Cross-Cutting Concerns Identified
- **Async image processing pipeline:** Upload/approval dispatches a Symfony Messenger message → worker processes (resize, 4bpp palette quantization) → sets image rendering_status to `ready`. Images only enter device pull pool when `ready`. Doctrine transport (consistent with aqua-iq).
- **Image rendering status:** Each image asset tracks `pending → processing → ready | failed` per device model/orientation. Rotation engine and device pull endpoint filter exclusively on `ready`.
- **Device identity:** MAC address as device identifier throughout — provisioning, image pull auth, ownership transfer.
- **Authorization tokens:** Single-use links with TTL for email approve/decline and hard-delete confirmation.
- **Soft-delete with retention rules:** Images retained until last approval removed; scheduled cleanup handles hard-delete.
- **Rotation engine:** Per-device schedule tracking, uniqueness window enforcement, next-image selection from `ready` pool only.
## Starter Template Evaluation
### Primary Technology Domain
Symfony full-stack web application with IoT device client. Stack pre-determined by user.
### Selected Starter: Symfony 8.0 `--webapp`
**Initialization Command:**
```bash
symfony new pictureframe --webapp
```
**Architectural Decisions Provided by Starter:**
**Language & Runtime:** PHP 8.4+, Symfony 8.0
**Templating:** Twig — used only for public flows (provisioning setup, email approve/decline pages). Authenticated app is a Vue 3 SPA; Twig is not used there.
**ORM & Database:** Doctrine ORM, PostgreSQL 16 (DDEV local), migrations via `doctrine/migrations`
**Security:** Symfony Security component — firewalls, voters, role hierarchy
**Email:** Symfony Mailer — handles sharing flows and hard-delete confirmations
**Build Tooling:** Vite (Vue SPA, outputs to `public/build/`). AssetMapper not used — no Stimulus, no Turbo, no `importmap.php`.
**Testing:** PHPUnit via `symfony/test-pack`
**Code Organization:** Standard Symfony structure — `src/Entity`, `src/Controller`, `src/Repository`, `src/Service`, `templates/` (public flows only) + `frontend/` (Vue SPA source)
**Additional packages required:**
- `symfony/messenger` + Doctrine transport — async image processing worker
- `symfony/scheduler` — rotation engine and scheduled cleanup
- Image processing library (TBD step 4: GD vs Imagick)
**Post-scaffold cleanup:** After `symfony new pictureframe --webapp`, remove `symfony/stimulus-bundle`, `symfony/ux-turbo`, and AssetMapper. Initialize the Vue SPA in `frontend/` with `npm create vite@latest frontend -- --template vue-ts`.
**Local Dev:** DDEV configured to mirror aqua-iq (PHP 8.4, Nginx-FPM, PostgreSQL 16)
**Note:** Project initialization using this command is the first implementation story.
## Core Architectural Decisions
### Decision Priority Analysis
**Critical Decisions (Block Implementation):**
- APP_BASE_URL established: `https://pictureframe.edholm.me` — bake into firmware as build constant
- Image rendering status lifecycle required before rotation engine can select images
- Imagick required on VPS before image processing worker can run
**Important Decisions (Shape Architecture):**
- Local filesystem image storage (not object storage)
- Symfony Messenger async processing with `ready` status gate
- Token entity pattern for all single-use authorization links
**Deferred Decisions (Post-MVP):**
- Caching layer (Redis) — add if query performance becomes an issue
- Additional display models beyond Waveshare 7.3" 800×480
### Data Architecture
**Image Storage:** Local filesystem on VPS. Pre-rendered assets stored at `storage/images/{image_id}/{device_model}_{orientation}.bin`. Originals stored separately at `storage/images/{image_id}/original.{ext}`. Paths stored in DB are relative to `STORAGE_PATH` env var. No object storage — personal scale doesn't warrant the complexity.
**Image Processing Library:** Imagick (PHP extension). Chosen for Floyd-Steinberg dithering quality on the 6-color e-ink palette. GD's palette quantization produces inferior results for this use case. Imagick must be installed on VPS and in DDEV container.
**Image Rendering Status:** Each pre-rendered asset (per device model + orientation) tracks status independently: `pending → processing → ready | failed`. Rotation engine and device pull endpoint filter exclusively on `ready`. Stored as an enum column on a `RenderedAsset` entity.
**Caching:** None in V1. PostgreSQL at personal scale is sufficient. Add Redis later if needed.
**Migrations:** Doctrine Migrations (`doctrine/migrations`). Standard Symfony approach.
### Authentication & Security
**Web Authentication:** Symfony form login — email + password, `remember_me` cookie. Standard Symfony Security firewall.
**Roles:** Two roles only — `ROLE_USER` and `ROLE_SUPER_ADMIN`. Configured in `security.yaml` with role hierarchy. Super admin is a single designated account.
**Authorization Token Pattern:** A `Token` entity — UUID primary key, `type` enum (`share_approve`, `share_decline`, `hard_delete_confirm`), `expires_at` datetime, `used_at` nullable datetime. Single-use enforced: token is invalid if `used_at IS NOT NULL` or `expires_at < NOW()`. TTL is configurable per token type.
**Device MAC Authentication:** Symfony controller guard on the image pull endpoint. Device sends MAC address via URL segment; server validates it exists in the `Device` entity and is linked to an active account before serving the binary asset.
**HTTPS:** Enforced at Nginx level on VPS. DDEV provides HTTPS locally.
### API & Communication Patterns
**Device API:** Single endpoint — `GET /api/device/{mac}/image` — returns raw binary (`application/octet-stream`). This is the only machine-to-machine API surface. No versioning scheme beyond URL stability guarantee (no breaking changes in V1).
**Web Controllers (authenticated app):** Symfony controllers return JSON responses for all authenticated app API calls under the `/api/` prefix. No Twig rendering for authenticated routes — Symfony serves the SPA shell only. Controllers use `JsonResponse` or Symfony Serializer.
**Web Controllers (public flows):** `/setup/{mac}`, `/token/{uuid}/approve`, `/token/{uuid}/decline` return Twig responses. These are the only controllers that render HTML directly.
**Email:** Symfony Mailer. Transactional emails: image share notification (with approve link), hard-delete confirmation. Authorization links embedded as tokenized URLs pointing to Symfony routes.
**Async Messaging:** Symfony Messenger with Doctrine transport. Messages dispatched on image upload and image approval. Worker consumes queue and processes images via Imagick. Sets `RenderedAsset` status to `ready` on completion, `failed` on exception.
**Scheduled Tasks:** Symfony Scheduler. Two recurring tasks:
- Rotation engine: per-device, fires on each device's configured interval to advance the current image pointer
- Cleanup job: periodic hard-delete of soft-deleted images with no remaining approvals
### Frontend Architecture
**Authenticated App — Vue 3 SPA:**
All authenticated routes are served by a Vue 3 SPA built with Vite + TypeScript strict mode. Vue Router handles client-side navigation; Pinia manages shared state (current user, device list, upload funnel state). SCSS modules scoped per SFC for component styles. Konva.js + Vue-Konva for the sticker canvas editor. No Stimulus, no Turbo, no AssetMapper.
Symfony serves the SPA shell (`public/build/index.html`) via a catch-all route for all authenticated paths. Vue Router takes over client-side navigation from that point.
**Public Flows — Symfony + Twig:**
Three routes remain as Symfony Twig pages with no Vue dependency:
- `/setup/{mac}` — device provisioning setup (post-QR scan)
- `/token/{uuid}/approve` — email approve page (no login required)
- `/token/{uuid}/decline` — email decline page (no login required)
These must work with images disabled, CSS disabled, and screen reader only.
**TypeScript:** Strict mode. `frontend/src/types/` holds interfaces mirroring every Symfony API response shape: `Device`, `Image`, `StickerLayer`, `RenderedAsset`, `Token`. The compiler surfaces API contract drift before it reaches deployed devices.
**SCSS:** Modules scoped per SFC (`<style scoped lang="scss">`). Global tokens in `frontend/src/styles/global.scss`. No Tailwind, no utility CSS framework, no pre-built component library.
**Build:** Vite outputs to `public/build/`. `vite.config.ts` sets `outDir: '../public/build'`. The Symfony catch-all controller renders `public/build/index.html` directly.
**Forms:** Symfony Form component is not used for authenticated app forms — Vue handles all form logic. Symfony Form is only used in Twig public flows if needed.
### Infrastructure & Deployment
**Domain:** `pictureframe.edholm.me` — APP_BASE_URL and API base URL. Must be configured on VPS before firmware build constants are set.
**VPS:** Single server. Nginx reverse proxy → PHP-FPM → Symfony. PostgreSQL on same host. Mirrors aqua-iq production setup.
**Local Dev:** DDEV — PHP 8.4, Nginx-FPM, PostgreSQL 16. Config mirrors aqua-iq `.ddev/config.yaml`.
**Git Hosting:** `git.edholm.me` (self-hosted Gitea/Forgejo). Credentials from existing projects.
**CI/CD:** Gitea Actions (if enabled on git.edholm.me) or SSH-based deploy script. Mirror aqua-iq deployment pattern.
**SSH/Server Access:** Documented in aqua-iq project. Same VPS, same access pattern.
### Decision Impact Analysis
**Implementation Sequence:**
1. DDEV setup + Symfony scaffold (`symfony new pictureframe --webapp`); remove Stimulus/Turbo/AssetMapper
2. Vue SPA scaffold in `frontend/` (`npm create vite@latest frontend -- --template vue-ts`); configure `vite.config.ts` to output to `public/build/`
3. Add Messenger, Scheduler, Imagick to Symfony
4. Domain + Nginx config on VPS
5. Core entities (User, Device, Image, RenderedAsset, Token)
6. Image processing worker
7. Symfony JSON API endpoints + SpaController catch-all
8. Vue SPA features (library, upload funnel, sticker editor, approvals, admin)
9. Firmware (after domain + API contract confirmed)
**Cross-Component Dependencies:**
- Firmware cannot be finalized until `pictureframe.edholm.me` is live and API endpoint format is confirmed
- Device pull endpoint depends on `RenderedAsset.status = ready`
- Rotation engine depends on uniqueness tracking and `ready` asset pool
- Email sharing depends on Token entity and Mailer configuration
## Implementation Patterns & Consistency Rules
### Entity & Enum Patterns
**PHP Backed Enums:** Use PHP 8.1 backed enums for all finite value sets.
```php
enum RenderStatus: string {
case Pending = 'pending';
case Processing = 'processing';
case Ready = 'ready';
case Failed = 'failed';
}
enum TokenType: string {
case ShareApprove = 'share_approve';
case ShareDecline = 'share_decline';
case HardDeleteConfirm = 'hard_delete_confirm';
}
enum Orientation: string {
case Landscape = 'landscape';
case Portrait = 'portrait';
}
```
Doctrine maps backed enums to VARCHAR columns via built-in PHP 8.1 enum type support. No string constants elsewhere.
### Repository Naming Convention
**Soft-Delete Awareness:** All repository methods that exclude soft-deleted records use the `findActive*` prefix.
- `findActiveByDevice(Device $device)` — images where `deleted_at IS NULL`
- `findActiveReadyByDevice(Device $device)` — images with `deleted_at IS NULL` AND `status = RenderStatus::Ready`
- `findBy*` — raw Doctrine finders, may include soft-deleted records
This makes soft-delete awareness visible at the call site rather than hidden in query logic.
### Image Storage Pattern
**Storage Location:** `storage/images/{image_id}/{device_model}_{orientation}.bin` for pre-rendered assets. Originals at `storage/images/{image_id}/original.{ext}`. Never `var/images/`.
**Database References:** Store paths relative to `STORAGE_PATH` root, not absolute paths. The storage root can move without a data migration.
**Environment Variable:** `STORAGE_PATH` — configured per environment. In DDEV, mapped to a local volume. On VPS, absolute path under the project root.
### Async Image Processing Pattern
**Exclusive Dispatch Points:** `ProcessImageMessage` is dispatched in exactly two places:
- `ImageService::upload()` — after persisting a new uploaded image
- `ImageService::approve()` — after a share-approval token is consumed
No other code dispatches `ProcessImageMessage`.
**Messenger Transport Configuration:**
```yaml
# config/packages/messenger.yaml
framework:
messenger:
transports:
image_processing:
dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
options:
queue_name: image_processing
retry_strategy:
max_retries: 1
delay: 1000
multiplier: 2
```
`max_retries: 1` — a single retry on transient failures. Failed messages set `RenderedAsset.status` to `RenderStatus::Failed` so the error surfaces in the admin UI.
### Device Pull Endpoint — Critical Response Codes
> **CRITICAL RULE:** `GET /api/device/{mac}/image` must return:
> - **`204 No Content`** — MAC is valid, device exists, but no `ready` image is currently available
> - **`404 Not Found`** — MAC is not registered in the system
> - **`200 OK`** — returns raw binary (`application/octet-stream`)
>
> **Never return 404 when the device is known but has no ready image.** The ESP32 firmware treats 204 as "wait and retry" and 404 as "this device is not configured." Confusing them causes the device to enter a permanent error state.
### Testing Strategy
**Unit Tests (`tests/Unit/`):** Pure logic, no database or I/O. Covers: enum behavior, token TTL calculation, image processing pipeline steps, rotation selection algorithm.
**Integration Tests (`tests/Integration/`):** Hit the real test database via Doctrine, using Symfony's `KernelTestCase`. Covers: repository `findActive*` methods, Messenger handler with real entity persistence, token single-use enforcement.
**Functional Tests (`tests/Functional/`):** Full HTTP stack via `WebTestCase`. Covers: device pull endpoint (204/404/200 response codes), form submissions, auth flows, email dispatch.
No database mocks. Integration tests use a real test database — mirrors the pattern from aqua-iq.
### Enforcement Guidelines
Mandatory rules for all code in this project:
1. **`findActive*` prefix** on all repository methods filtering out soft-deleted records
2. **`ImageService::upload()` and `ImageService::approve()`** are the only dispatch points for `ProcessImageMessage`
3. **`204` not `404`** when device is known but has no ready image
4. **`storage/images/`** for all file storage — never `var/images/`
5. **Relative paths** in the database — never absolute filesystem paths
6. **`max_retries: 1`** on the `image_processing` Messenger transport
7. **PHP backed enums** for `RenderStatus`, `TokenType`, `Orientation` — no raw string constants
8. **Rotation engine and device pull endpoint** filter exclusively on `RenderStatus::Ready`
## Project Structure & Boundaries
### Requirements to Structure Mapping
**User & Account Management**`src/Controller/SecurityController.php`, `src/Controller/Api/UserApiController.php`, `src/Entity/User.php`, `src/Repository/UserRepository.php`
**Device Management**`src/Controller/Api/DeviceApiController.php`, `src/Entity/Device.php`, `src/Service/DeviceService.php`, `src/Repository/DeviceRepository.php`, `frontend/src/views/HomeView.vue`
**Image Library**`src/Controller/Api/ImageApiController.php`, `src/Entity/Image.php`, `src/Entity/RenderedAsset.php`, `src/Service/ImageService.php`, `src/Service/ImageProcessingService.php`, `frontend/src/views/LibraryView.vue`
**Image Approval & Sharing**`src/Controller/Token/TokenActionController.php`, `src/Entity/Token.php`, `src/Service/TokenService.php`, `templates/token/` (email approve/decline pages), `frontend/src/components/ApproveCard.vue`
**Device Provisioning**`src/Controller/Device/DeviceProvisionController.php`, `templates/device/provision.html.twig`
**Image Rotation & Cycle Engine**`src/Schedule/RotationSchedule.php`, `src/Service/RotationService.php`
**Display & Status (device pull endpoint)**`src/Controller/Api/DeviceImageController.php`
**Admin & Moderation**`src/Controller/Admin/`, `frontend/src/views/AdminView.vue` (if applicable)
**Cross-Cutting: Async Processing**`src/Message/ProcessImageMessage.php`, `src/MessageHandler/ProcessImageMessageHandler.php`, `config/packages/messenger.yaml`
**Cross-Cutting: Scheduled Tasks**`src/Schedule/RotationSchedule.php`, `src/Schedule/ImageCleanupSchedule.php`, `config/packages/scheduler.yaml`
**Cross-Cutting: Enums**`src/Enum/RenderStatus.php`, `src/Enum/TokenType.php`, `src/Enum/Orientation.php`
### Complete Project Directory Structure
```
pictureframe/
├── .ddev/
│ ├── config.yaml ← PHP 8.4, nginx-fpm, pgsql 16 — mirror aqua-iq
│ └── docker-compose.imagick.yaml ← adds Imagick to web container
├── .gitea/
│ └── workflows/
│ └── ci.yml ← Gitea Actions: lint + test + vite build on push
├── frontend/ ← Vue 3 SPA source (Vite + TypeScript)
│ ├── src/
│ │ ├── assets/
│ │ │ └── stickers/ ← SVG sticker assets (Seasonal, Holidays, Fun, Family, Nature)
│ │ ├── components/
│ │ │ ├── base/
│ │ │ │ ├── BaseButton.vue
│ │ │ │ ├── BaseInput.vue
│ │ │ │ ├── BaseBottomSheet.vue
│ │ │ │ ├── BaseCard.vue
│ │ │ │ ├── BaseChip.vue
│ │ │ │ └── BaseToast.vue
│ │ │ ├── FrameCard.vue
│ │ │ ├── CropEditor.vue
│ │ │ ├── StickerCanvas.vue
│ │ │ ├── StickerTray.vue
│ │ │ ├── DevicePicker.vue
│ │ │ ├── PhotoThumb.vue
│ │ │ ├── ShareSheet.vue
│ │ │ ├── ApproveCard.vue
│ │ │ └── BottomNav.vue
│ │ ├── router/
│ │ │ └── index.ts ← Vue Router; catch-all handled by Symfony
│ │ ├── stores/
│ │ │ ├── auth.ts ← current user, login state
│ │ │ ├── devices.ts ← device list, current device
│ │ │ └── upload.ts ← upload funnel state, sticker composition
│ │ ├── styles/
│ │ │ ├── _breakpoints.scss ← $bp-tablet: 640px, $bp-desktop: 960px
│ │ │ ├── _tokens.scss ← CSS custom properties for all 6 themes
│ │ │ └── global.scss ← reset, typography, base layout
│ │ ├── types/
│ │ │ ├── Device.ts
│ │ │ ├── Image.ts
│ │ │ ├── StickerLayer.ts ← { id, type, x, y, scale, rotation }
│ │ │ ├── RenderedAsset.ts
│ │ │ └── Token.ts
│ │ ├── views/
│ │ │ ├── HomeView.vue ← FrameList — FrameCard per device
│ │ │ ├── LibraryView.vue ← photo grid, search, tabs (All/Mine/Shared)
│ │ │ ├── UploadView.vue ← funnel: crop → sticker → device picker
│ │ │ └── SettingsView.vue
│ │ ├── App.vue
│ │ └── main.ts
│ ├── index.html
│ ├── package.json
│ ├── tsconfig.json
│ └── vite.config.ts ← outDir: '../public/build'
├── bin/
│ └── console
├── config/
│ ├── packages/
│ │ ├── doctrine.yaml
│ │ ├── doctrine_migrations.yaml
│ │ ├── mailer.yaml
│ │ ├── messenger.yaml ← image_processing transport, max_retries: 1
│ │ ├── scheduler.yaml
│ │ ├── security.yaml ← form_login, remember_me, ROLE_SUPER_ADMIN hierarchy
│ │ └── twig.yaml
│ ├── routes.yaml ← catch-all route → SpaController for authenticated paths
│ ├── routes/
│ │ └── api.yaml ← /api/device/{mac}/image + /api/* app endpoints
│ └── services.yaml
├── migrations/
├── public/
│ ├── build/ ← gitignored; Vite output (index.html + hashed assets)
│ └── index.php
├── src/
│ ├── Controller/
│ │ ├── Api/
│ │ │ ├── DeviceImageController.php ← GET /api/device/{mac}/image → 200/204/404 (binary)
│ │ │ ├── DeviceApiController.php ← JSON CRUD for devices (Vue SPA)
│ │ │ ├── ImageApiController.php ← JSON CRUD for images, upload, share (Vue SPA)
│ │ │ └── UserApiController.php ← current user, settings (Vue SPA)
│ │ ├── Admin/
│ │ │ ├── AdminDashboardController.php
│ │ │ └── AdminModerationController.php
│ │ ├── SpaController.php ← catch-all; renders public/build/index.html
│ │ ├── SecurityController.php ← login/logout (form POST, JSON response)
│ │ ├── Device/
│ │ │ └── DeviceProvisionController.php ← GET/POST /setup/{mac} → Twig
│ │ └── Token/
│ │ └── TokenActionController.php ← /token/{uuid}/approve|decline → Twig
│ ├── Entity/
│ │ ├── Device.php
│ │ ├── Image.php
│ │ ├── RenderedAsset.php ← per device-model+orientation, RenderStatus
│ │ ├── Token.php ← UUID PK, TokenType, expires_at, used_at
│ │ └── User.php
│ ├── Enum/
│ │ ├── Orientation.php
│ │ ├── RenderStatus.php
│ │ └── TokenType.php
│ ├── Message/
│ │ └── ProcessImageMessage.php ← DTO: imageId, deviceModel, orientation
│ ├── MessageHandler/
│ │ └── ProcessImageMessageHandler.php ← Imagick → .bin → sets RenderStatus::Ready
│ ├── Repository/
│ │ ├── DeviceRepository.php
│ │ ├── ImageRepository.php ← findActiveByDevice(), findActiveReadyByDevice()
│ │ ├── RenderedAssetRepository.php ← findReadyForDevice()
│ │ ├── TokenRepository.php ← findValidToken() (unused + unexpired)
│ │ └── UserRepository.php
│ ├── Schedule/
│ │ ├── ImageCleanupSchedule.php ← hard-delete orphaned soft-deleted images
│ │ └── RotationSchedule.php ← advance current_image pointer per device
│ ├── Service/
│ │ ├── DeviceService.php
│ │ ├── ImageProcessingService.php ← Imagick: resize → 4bpp dither → .bin
│ │ ├── ImageService.php ← upload() and approve() — exclusive dispatch points
│ │ ├── RotationService.php ← next-image selection, uniqueness window
│ │ └── TokenService.php ← issue and consume single-use tokens
│ └── Kernel.php
├── storage/
│ └── images/ ← gitignored; STORAGE_PATH points here
├── templates/
│ ├── device/
│ │ └── provision.html.twig ← /setup/{mac} public provisioning page
│ ├── token/
│ │ ├── approve.html.twig ← email approve page (no login)
│ │ └── decline.html.twig ← email decline page (no login)
│ └── base_public.html.twig ← minimal base for public Twig pages only
├── tests/
│ ├── Functional/
│ │ ├── Api/
│ │ │ ├── DeviceImageControllerTest.php
│ │ │ ├── DeviceApiControllerTest.php
│ │ │ └── ImageApiControllerTest.php
│ │ └── Token/
│ │ └── TokenActionControllerTest.php
│ ├── Integration/
│ │ ├── MessageHandler/
│ │ │ └── ProcessImageMessageHandlerTest.php
│ │ ├── Repository/
│ │ │ ├── ImageRepositoryTest.php
│ │ │ └── RenderedAssetRepositoryTest.php
│ │ └── Service/
│ │ └── TokenServiceTest.php
│ └── Unit/
│ ├── Enum/
│ │ └── RenderStatusTest.php
│ └── Service/
│ ├── ImageProcessingServiceTest.php
│ └── RotationServiceTest.php
├── var/
│ ├── cache/
│ └── log/
├── .env
├── .env.local ← gitignored; STORAGE_PATH, DATABASE_URL, MAILER_DSN
├── .env.test
├── .gitignore
├── composer.json
├── composer.lock
├── phpunit.xml.dist
└── symfony.lock
```
### Architectural Boundaries
**API Boundary — Device Pull Endpoint**
`GET /api/device/{mac}/image` is the only machine-to-machine surface. MAC address validated against `Device` entity before serving. Returns `application/octet-stream`, 204, or 404. Isolated in `config/routes/api.yaml`.
**Web Application Boundary**
Authenticated routes are gated by Symfony form-login firewall. `SpaController` serves `public/build/index.html` as the catch-all — Vue Router handles client-side navigation from there. All authenticated data flows through `/api/*` JSON endpoints. `ROLE_SUPER_ADMIN` gates admin API controllers. Public flows (`/setup/{mac}`, `/token/{uuid}/approve|decline`) are outside the firewall and return Twig responses — no Vue involved.
**Async Processing Boundary**
`ImageService` → Messenger bus → `ProcessImageMessageHandler`. Handler is the only component writing to `storage/images/`. `RenderedAsset.status` is the only signal crossing this boundary.
**Scheduled Task Boundary**
`RotationSchedule` and `ImageCleanupSchedule` run in the worker process — no HTTP context, no session access. Database reads and writes only.
**Storage Boundary**
`storage/images/` written by `ProcessImageMessageHandler`, read by `DeviceImageController`. All other code uses `STORAGE_PATH` env var. Paths in DB are always relative.
### Integration Points & Data Flow
**Image Upload → Display**
```
User uploads → ImageService::upload() persists Image → dispatches ProcessImageMessage
→ Worker: Imagick resize + dither → storage/images/{id}/waveshare73_landscape.bin
→ RenderedAsset.status = Ready
→ RotationSchedule advances device pointer
→ ESP32: GET /api/device/{mac}/image → 200 + binary stream
```
**Image Sharing Flow**
```
User shares image with owner → ImageService creates Token (ShareApprove/ShareDecline)
→ Mailer sends tokenized URL
→ Owner clicks link → TokenActionController::approve()
→ TokenService consumes token → ImageService::approve() dispatches ProcessImageMessage
→ Image enters ready pool for recipient device
```
**Soft-Delete & Cleanup Flow**
```
User soft-deletes image → Image.deleted_at set (excluded by findActive*)
→ If last approval removed → ImageCleanupSchedule hard-deletes image + storage files
```
## Architecture Validation Results
### Coherence Validation ✅
**Decision Compatibility:** PHP 8.4 + Symfony 8.0 + Doctrine ORM + Messenger + Scheduler + PostgreSQL 16 is a fully supported combination. Imagick is a standalone PHP extension with no framework conflicts. Vue 3 + Vite + TypeScript strict + SCSS modules + Konva.js is a standard, well-supported SPA stack. The Symfony/Vue boundary is clean: Symfony serves the SPA shell and JSON API; Vue owns all authenticated UI rendering. No build-step conflicts — Vite and Symfony operate independently.
**Pattern Consistency:** `findActive*` naming, `storage/images/` path convention, exclusive Messenger dispatch points, and backed enum usage are consistently applied. One contradiction (`var/images/` in Data Architecture section) found during validation and corrected — all references now point to `storage/images/`.
**Structure Alignment:** Every controller, service, entity, and test file has a defined home. All five boundaries (API, web, async, storage, scheduled) are respected in the project structure.
### Requirements Coverage Validation ✅
All 8 FR domains covered:
| Domain | Architectural Support |
|---|---|
| User & Account Management | `User` entity, form login, `SecurityController` |
| Device Management | `Device` entity, `DeviceController`, `DeviceService` |
| Image Library | `Image` entity, `ImageLibraryController`, `ImageService` |
| Image Approval & Sharing | `Token` entity, `TokenService`, `TokenActionController` |
| Device Provisioning | `DeviceProvisionController` |
| Image Rotation & Cycle Engine | `RotationSchedule`, `RotationService` |
| Display & Status | `DeviceImageController`, `RenderedAsset` |
| Admin & Moderation | `AdminDashboardController`, `AdminModerationController` |
All 9 NFRs covered: pre-rendering ≤10s (async Messenger), device pull ≤10s (direct binary stream), web pages ≤3s (standard Symfony), rotation ±5min (Scheduler), HTTPS (Nginx), MAC auth (controller guard), single-use links (Token entity), scheduled cleanup (ImageCleanupSchedule), zero blank screens (204 not 404 + last image persistence).
### Implementation Readiness Validation ✅
All critical decisions documented. Enforcement guidelines provide 8 explicit, checkable rules. Project tree is specific with no generic placeholders. The 204/404 distinction is called out as a critical rule with the reason documented.
### Gap Analysis
**Critical Gaps:** None.
**Minor (non-blocking):**
- `.env.example` not in project tree — add alongside `.env` on scaffold
- `device_model` field type on `RenderedAsset` not fully specified — for V1 with a single display (Waveshare 7.3" 800×480), a string constant or single-value enum is sufficient
### Architecture Completeness Checklist
**✅ Requirements Analysis** — context, scale, constraints, cross-cutting concerns
**✅ Architectural Decisions** — stack, data architecture, auth, API patterns, infrastructure
**✅ Implementation Patterns** — enums, `findActive*`, storage, Messenger, 204/404, testing, enforcement rules
**✅ Project Structure** — complete directory tree, boundaries, FR mapping, data flow diagrams
**✅ Validation** — coherence, coverage, readiness, gaps
### Architecture Readiness Assessment
**Overall Status: READY FOR IMPLEMENTATION**
**Confidence: High.** Coherent, fully covers the PRD, no critical gaps, sufficient specificity for consistent implementation.
**First Implementation Step:**
```bash
symfony new pictureframe --webapp
```
### Implementation Handoff
**AI Agent Guidelines:**
- Follow all architectural decisions exactly as documented
- Apply the 8 enforcement rules in every code generation task
- Respect the `storage/images/` boundary — never write image assets to `var/`
- Confirm `204 vs 404` on every implementation of the device pull endpoint
- Reference this document for all architectural questions; do not invent decisions not documented here