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:
Ullrich Schäfer 2026-03-29 09:53:41 +02:00
parent 9c7891402f
commit 0a330e4466
No known key found for this signature in database
GPG key ID: A32FF691A0F752D9
46 changed files with 2968 additions and 0 deletions

View file

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View 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.

View 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

View 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