Break up route-features into focused specs, add new changes
Archive the monolithic route-features spec and replace with 9 focused OpenSpec changes: multi-day-routes, waypoint-notes (with POI snapping), undo-redo, local-dev-stack, route-sharing, route-discovery, activity-photos, osm-overlays, plus the existing changelog and komoot-import. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9c7891402f
commit
0a330e4466
46 changed files with 2968 additions and 0 deletions
2
openspec/changes/activity-photos/.openspec.yaml
Normal file
2
openspec/changes/activity-photos/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
187
openspec/changes/activity-photos/design.md
Normal file
187
openspec/changes/activity-photos/design.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
## Context
|
||||
|
||||
The Journal stores activities in `journal.activities` with text fields (name,
|
||||
description, stats) and an optional GPX track. The existing `photos` jsonb
|
||||
column on activities was a placeholder -- it stores an array of strings but has
|
||||
no upload pipeline, no storage backend, and no UI.
|
||||
|
||||
The architecture plan calls for Garage (S3-compatible, self-hosted) as the
|
||||
object store. The Garage container is already defined in docker-compose.yml but
|
||||
commented out. The Journal app has no S3 client code.
|
||||
|
||||
Activity detail page (`activities.$id.tsx`) currently shows stats, description,
|
||||
and route linking -- no photo display.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Photo upload on activity pages, stored in S3-compatible storage (Garage)
|
||||
- Photo gallery display on activity detail page
|
||||
- Client-side image processing (resize, EXIF strip) before upload
|
||||
- Presigned URL upload flow (no server proxying)
|
||||
- Soft delete with S3 cleanup
|
||||
- Privacy manifest update
|
||||
|
||||
**Non-Goals:**
|
||||
- Video uploads
|
||||
- GPS-tagged photo placement on route maps
|
||||
- Photo editing, cropping, or filters
|
||||
- CDN or edge caching
|
||||
- Cross-activity photo albums
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1: Storage -- Garage (S3-compatible, self-hosted)
|
||||
|
||||
Enable the existing Garage container in docker-compose.yml. Single bucket
|
||||
(`activity-photos`) for all photo objects. Keys follow the pattern:
|
||||
|
||||
```
|
||||
{activityId}/{photoId}.webp
|
||||
```
|
||||
|
||||
Garage runs as an internal service -- not exposed to the public internet. The
|
||||
Journal app generates presigned URLs for both upload (PUT) and download (GET).
|
||||
This keeps the storage layer behind the application.
|
||||
|
||||
Environment variables added to the Journal service:
|
||||
- `S3_ENDPOINT` -- internal Garage URL (e.g. `http://garage:3900`)
|
||||
- `S3_ACCESS_KEY` / `S3_SECRET_KEY` -- Garage API credentials
|
||||
- `S3_BUCKET` -- bucket name (`activity-photos`)
|
||||
- `S3_PUBLIC_ENDPOINT` -- optional public URL for serving photos (falls back
|
||||
to presigned GET URLs if not set)
|
||||
|
||||
### D2: Upload flow -- presigned PUT URLs
|
||||
|
||||
The client never sends photo bytes to the Journal app server. Flow:
|
||||
|
||||
1. Client selects photo(s) and processes them (resize, EXIF strip, convert to
|
||||
WebP)
|
||||
2. Client requests a presigned PUT URL from `POST /api/activities/:id/photos`
|
||||
with metadata (filename, content type, dimensions)
|
||||
3. Server validates the user owns the activity, generates a presigned PUT URL
|
||||
(5 min expiry), creates a pending `activity_photos` row, returns the URL
|
||||
and photo ID
|
||||
4. Client uploads directly to Garage using the presigned PUT URL
|
||||
5. Client confirms upload via `PATCH /api/activities/:id/photos/:photoId` with
|
||||
`status: 'uploaded'`
|
||||
6. Server verifies the object exists in S3 and marks the photo as active
|
||||
|
||||
This avoids proxying large files through the Node.js server and keeps the app
|
||||
server lightweight. Pending photos that are never confirmed are cleaned up by
|
||||
a periodic check (or on next page load).
|
||||
|
||||
### D3: Schema -- `journal.activity_photos` table
|
||||
|
||||
```sql
|
||||
journal.activity_photos (
|
||||
id text PRIMARY KEY,
|
||||
activity_id text NOT NULL REFERENCES journal.activities(id) ON DELETE CASCADE,
|
||||
s3_key text NOT NULL,
|
||||
alt_text text,
|
||||
width integer,
|
||||
height integer,
|
||||
size_bytes integer,
|
||||
status text NOT NULL DEFAULT 'pending', -- 'pending', 'active', 'deleted'
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
```
|
||||
|
||||
The existing `photos` jsonb column on `journal.activities` is superseded by
|
||||
this table. It can be dropped in a future migration once this feature is stable.
|
||||
|
||||
Photos are ordered by `created_at` (upload order). No explicit `position`
|
||||
column initially -- reordering can be added later if users request it.
|
||||
|
||||
### D4: Image processing -- client-side resize and EXIF strip
|
||||
|
||||
All image processing happens in the browser before upload:
|
||||
|
||||
- **Resize**: Max 2048px on the long edge. Maintains aspect ratio. Uses
|
||||
canvas `drawImage` for the resize.
|
||||
- **Format**: Convert to WebP (broad browser support, good compression). Fall
|
||||
back to JPEG for browsers without WebP encode support.
|
||||
- **EXIF stripping**: Read the image via canvas `drawImage` -- this
|
||||
inherently drops EXIF data since canvas does not preserve metadata. This
|
||||
removes GPS coordinates, camera info, timestamps, and other metadata that
|
||||
users may not want to share.
|
||||
- **Size limit**: Max 5 MB per photo after processing. The presigned URL has a
|
||||
content-length condition enforcing this.
|
||||
|
||||
No server-side image processing. This keeps the server simple and avoids
|
||||
adding Sharp or similar native dependencies.
|
||||
|
||||
### D5: Gallery -- grid display with lightbox
|
||||
|
||||
Activity detail page shows photos in a responsive grid:
|
||||
|
||||
- 1 column on mobile, 2 on tablet, 3 on desktop
|
||||
- Aspect ratio preserved via `object-cover` in fixed-height cells
|
||||
- Lazy loading via `loading="lazy"` on `<img>` tags
|
||||
- Click opens a lightbox overlay with full-size image, left/right navigation,
|
||||
close on escape/click-outside
|
||||
- Alt text shown below image in lightbox (if provided)
|
||||
- Owner sees an "Add photos" button and delete icons on each photo
|
||||
|
||||
The gallery uses presigned GET URLs with 1-hour expiry, generated at page load
|
||||
time in the loader. For the lightbox, the same URLs are reused (they are valid
|
||||
long enough for a viewing session).
|
||||
|
||||
### D6: Deletion -- soft delete with background cleanup
|
||||
|
||||
Deleting a photo sets `status = 'deleted'` in the database. The photo
|
||||
immediately disappears from the UI. The actual S3 object is cleaned up:
|
||||
|
||||
- On the next page load of the activity (loader checks for deleted photos and
|
||||
removes them from S3)
|
||||
- Or by a periodic cleanup that runs on app startup and every 24 hours
|
||||
|
||||
This avoids the complexity of a separate job queue while ensuring S3 objects
|
||||
are eventually cleaned up. The cleanup is idempotent -- deleting a
|
||||
non-existent S3 key is a no-op.
|
||||
|
||||
### D7: Privacy -- manifest and EXIF documentation
|
||||
|
||||
Update the privacy page to document:
|
||||
|
||||
- **Photo storage**: Photos uploaded to activities are stored in S3-compatible
|
||||
object storage (Garage) on the same server as the Journal. Self-hosters
|
||||
control their own storage.
|
||||
- **EXIF stripping**: All photo metadata (GPS coordinates, camera info,
|
||||
timestamps) is stripped in the browser before upload. The server never sees
|
||||
original EXIF data.
|
||||
- **Deletion**: Deleted photos are removed from storage. No backups are kept
|
||||
beyond what the storage system provides.
|
||||
- **Data export**: Photos are included in data exports (future).
|
||||
|
||||
### D8: Limits -- per-activity and per-photo constraints
|
||||
|
||||
- Max **20 photos** per activity. Enforced server-side when generating
|
||||
presigned URLs. Client shows remaining count.
|
||||
- Max **5 MB** per photo (after client-side processing). Enforced via
|
||||
presigned URL content-length condition.
|
||||
- Max **2048px** long edge (client-side resize). Not enforced server-side --
|
||||
the 5 MB limit is the hard constraint.
|
||||
- Supported input formats: JPEG, PNG, WebP, HEIC (converted to WebP on
|
||||
client).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Garage operational complexity**: Adding another container increases the
|
||||
operational surface. Garage is lightweight but needs monitoring. Mitigate by
|
||||
adding a Garage health check to the existing Prometheus setup.
|
||||
- **Client-side processing reliability**: Canvas-based resize may behave
|
||||
differently across browsers, especially for HEIC on non-Safari browsers.
|
||||
Mitigate by testing on Chrome, Firefox, and Safari, and falling back to
|
||||
JPEG if WebP encoding fails.
|
||||
- **Presigned URL expiry**: If a user's upload takes longer than 5 minutes
|
||||
(slow connection, large batch), the presigned URL expires. Mitigate by
|
||||
generating URLs one at a time as each upload starts, not all upfront.
|
||||
- **No server-side validation of image content**: The server trusts that the
|
||||
client uploaded a valid image. A malicious user could upload non-image data.
|
||||
Mitigate by checking Content-Type on the S3 object during confirmation step
|
||||
and adding server-side validation later if abuse occurs.
|
||||
- **EXIF stripping depends on canvas**: The canvas approach strips EXIF
|
||||
reliably but loses all metadata including orientation. The resize step
|
||||
handles orientation via `createImageBitmap` with `imageOrientation: 'from-image'`
|
||||
before drawing to canvas.
|
||||
62
openspec/changes/activity-photos/proposal.md
Normal file
62
openspec/changes/activity-photos/proposal.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
## Why
|
||||
|
||||
Activities are text-only. Users complete a hike or bike tour and want to share
|
||||
photos from the trip alongside their route and stats, but there is no way to
|
||||
attach images. This is the most basic social feature missing from the Journal --
|
||||
without it, activities feel like spreadsheet rows rather than trip reports.
|
||||
|
||||
The infrastructure for photo storage (Garage, an S3-compatible self-hosted
|
||||
object store) is already planned and commented out in docker-compose.yml. This
|
||||
change enables it and builds the upload + display pipeline.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Infrastructure**: Enable the Garage container in docker-compose, create a
|
||||
bucket for activity photos, add S3 client utility to the Journal app.
|
||||
- **Schema**: New `journal.activity_photos` table linking photos to activities
|
||||
with metadata (S3 key, alt text, dimensions).
|
||||
- **Upload flow**: Server generates presigned PUT URLs, client uploads directly
|
||||
to Garage. Photos are resized and EXIF-stripped on the client before upload.
|
||||
- **Display**: Photo gallery grid on the activity detail page with lightbox
|
||||
view and lazy loading.
|
||||
- **Deletion**: Soft delete with background S3 cleanup.
|
||||
- **Privacy**: Photo storage documented in privacy manifest, EXIF stripping
|
||||
explained.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `activity-photos`: Photo upload, storage, gallery display, and deletion on
|
||||
Journal activities
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `infrastructure`: Garage S3 container enabled, bucket provisioned
|
||||
- `activity-management`: Activity detail page gains photo gallery and upload UI
|
||||
- `privacy-manifest`: Documents photo storage, EXIF handling, S3 retention
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Video uploads**: Photos only. Video adds transcoding complexity.
|
||||
- **GPS-tagged photo mapping**: Placing photos on the route map by GPS
|
||||
coordinates is a future enhancement, not part of this change.
|
||||
- **Photo editing or filters**: Upload as-is (after resize and EXIF strip).
|
||||
- **CDN**: Serve directly from Garage via presigned GET URLs. CDN layer is a
|
||||
future optimization.
|
||||
- **Photo albums or collections**: Photos belong to a single activity. No
|
||||
cross-activity albums.
|
||||
- **Bulk import from external services**: Manual upload only.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Infrastructure**: Garage container added to docker-compose, new volume for
|
||||
object storage, Caddy config for S3 endpoint (internal only)
|
||||
- **Database**: New `journal.activity_photos` table
|
||||
- **Dependencies**: `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`
|
||||
for presigned URL generation; client-side image resize library
|
||||
- **Files**: Activity detail page, new upload component, new gallery component,
|
||||
storage server utility, privacy page update
|
||||
- **Environment variables**: `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`,
|
||||
`S3_BUCKET` added to Journal service
|
||||
- **Privacy**: Photo storage and EXIF handling documented in manifest
|
||||
61
openspec/changes/activity-photos/tasks.md
Normal file
61
openspec/changes/activity-photos/tasks.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
## 1. Infrastructure
|
||||
|
||||
- [ ] 1.1 Enable Garage container in `infrastructure/docker-compose.yml`: uncomment the existing service, add `garage_data` volume, add health check, add `garage.toml` config file with single-node setup
|
||||
- [ ] 1.2 Create `activity-photos` bucket in Garage: add an init script or document the `garage bucket create` command, generate API key with read/write access to the bucket
|
||||
- [ ] 1.3 Add S3 environment variables to Journal service in docker-compose: `S3_ENDPOINT`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET`, `S3_PUBLIC_ENDPOINT`
|
||||
|
||||
## 2. S3 Client & Server Utilities
|
||||
|
||||
- [ ] 2.1 Add `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` dependencies to the Journal app
|
||||
- [ ] 2.2 Create `apps/journal/app/lib/storage.server.ts`: S3 client singleton, `generateUploadUrl(key, contentType, maxBytes)`, `generateDownloadUrl(key, expiresIn)`, `deleteObject(key)`, `headObject(key)` functions
|
||||
- [ ] 2.3 Add env validation for S3 variables in Journal app startup (fail loudly if missing when photo feature is used)
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
- [ ] 3.1 Add `activityPhotos` table to `packages/db/src/schema/journal.ts`: id, activityId (FK to activities, cascade delete), s3Key, altText, width, height, sizeBytes, status (pending/active/deleted), createdAt
|
||||
- [ ] 3.2 Push schema with `pnpm db:push` and verify table exists in local PostgreSQL
|
||||
|
||||
## 4. Upload API
|
||||
|
||||
- [ ] 4.1 Create `apps/journal/app/routes/api.activities.$id.photos.ts`: POST handler that validates ownership, checks photo count limit (max 20), generates presigned PUT URL, creates pending `activity_photos` row, returns URL + photo ID
|
||||
- [ ] 4.2 Create `apps/journal/app/routes/api.activities.$id.photos.$photoId.ts`: PATCH handler to confirm upload (verify S3 object exists via HEAD, set status to active), DELETE handler to soft-delete (set status to deleted)
|
||||
- [ ] 4.3 Register both API routes in `apps/journal/app/routes.ts`
|
||||
|
||||
## 5. Client-Side Image Processing
|
||||
|
||||
- [ ] 5.1 Create `apps/journal/app/lib/image-processing.ts`: `resizeImage(file, maxDimension)` using canvas, outputs WebP blob (JPEG fallback), strips EXIF by virtue of canvas redraw, handles orientation via `createImageBitmap`
|
||||
- [ ] 5.2 Create `apps/journal/app/components/PhotoUploader.tsx`: file input (accept image types), processes each file through `resizeImage`, requests presigned URL from API, uploads to S3 via fetch PUT, confirms via PATCH, shows progress per photo
|
||||
- [ ] 5.3 Add size and count validation in the uploader: max 5 MB per photo after processing, max 20 photos per activity, show clear error messages
|
||||
|
||||
## 6. Photo Display
|
||||
|
||||
- [ ] 6.1 Update activity detail loader (`activities.$id.tsx`) to fetch active photos for the activity and generate presigned GET URLs (1 hour expiry)
|
||||
- [ ] 6.2 Create `apps/journal/app/components/PhotoGallery.tsx`: responsive grid (1/2/3 columns), `object-cover` thumbnails, `loading="lazy"`, click to open lightbox
|
||||
- [ ] 6.3 Create `apps/journal/app/components/PhotoLightbox.tsx`: full-screen overlay, left/right navigation, close on escape/backdrop click, alt text display
|
||||
- [ ] 6.4 Integrate PhotoGallery into activity detail page, show "Add photos" button for owner
|
||||
|
||||
## 7. Photo Management
|
||||
|
||||
- [ ] 7.1 Add delete button (trash icon) on each photo in gallery view (owner only), calls DELETE endpoint, optimistically removes from UI
|
||||
- [ ] 7.2 Add alt text editing: inline text input below each photo in edit mode, saves via PATCH endpoint
|
||||
|
||||
## 8. Cleanup
|
||||
|
||||
- [ ] 8.1 Add cleanup logic in `storage.server.ts`: function to find photos with status `deleted` or `pending` older than 1 hour, delete S3 objects, remove database rows
|
||||
- [ ] 8.2 Call cleanup on activity detail page load (non-blocking) and on app startup
|
||||
|
||||
## 9. Privacy Manifest
|
||||
|
||||
- [ ] 9.1 Update `apps/journal/app/routes/privacy.tsx`: add "Photos" section documenting S3 storage, EXIF stripping, deletion behavior, data export plans
|
||||
- [ ] 9.2 Add note about EXIF stripping in the upload UI (tooltip or help text explaining GPS/metadata removal)
|
||||
|
||||
## 10. i18n
|
||||
|
||||
- [ ] 10.1 Add translation keys (en + de) for: "Add photos", "Delete photo", "Photo uploaded", upload errors, photo count limit, alt text placeholder, EXIF stripping explanation, privacy manifest photo section
|
||||
|
||||
## 11. Testing
|
||||
|
||||
- [ ] 11.1 Unit tests for `image-processing.ts`: verify resize output dimensions, WebP output, size within limits, EXIF data absent from output
|
||||
- [ ] 11.2 Unit tests for `storage.server.ts`: presigned URL generation, delete, head object (mock S3 client)
|
||||
- [ ] 11.3 Unit tests for photo API routes: ownership validation, count limits, status transitions (pending -> active -> deleted)
|
||||
- [ ] 11.4 E2E test: upload a photo on an activity, verify it appears in the gallery, delete it, verify it disappears
|
||||
Loading…
Add table
Add a link
Reference in a new issue