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,255 @@
## Context
The Journal stores routes in `journal.routes` with an `ownerId` foreign key to
`journal.users`. Route versions live in `journal.route_versions` with a
`routeId` reference. Activities live in `journal.activities` with an `ownerId`.
Currently, all route access is implicitly owner-only: the route detail loader
returns the route regardless of who requests it, but the route list only shows
the current user's routes. There is no visibility column, no sharing table, and
no permission checks beyond ownership.
The architecture doc defines a permission matrix with private/public/shared
visibility, view/edit permission levels, and forking. The original
`route-features` spec designed these alongside spatial search, multi-day routes,
and photos. This design covers only the sharing, permissions, and forking scope.
## Decisions
### D1: Visibility enum -- private and public
Add a `visibility` column to `journal.routes` and `journal.activities` using a
PostgreSQL enum type `journal.visibility_enum` with values `private` and
`public`. Default is `private`.
```sql
CREATE TYPE journal.visibility_enum AS ENUM ('private', 'public');
ALTER TABLE journal.routes ADD COLUMN visibility journal.visibility_enum NOT NULL DEFAULT 'private';
ALTER TABLE journal.activities ADD COLUMN visibility journal.visibility_enum NOT NULL DEFAULT 'private';
```
In Drizzle:
```typescript
import { pgEnum } from "drizzle-orm/pg-core";
export const visibilityEnum = journalSchema.enum("visibility_enum", ["private", "public"]);
// On routes table:
visibility: visibilityEnum("visibility").notNull().default("private"),
// On activities table:
visibility: visibilityEnum("visibility").notNull().default("private"),
```
Only two values for now. A `followers_only` value can be appended to the enum
later when the follow system exists. Starting minimal avoids unused code paths
and UI states.
### D2: Route shares table
Create `journal.route_shares` to store per-user sharing permissions:
```typescript
export const routeSharePermission = journalSchema.enum(
"route_share_permission",
["view", "edit"],
);
export const routeShares = journalSchema.table("route_shares", {
id: text("id").primaryKey(),
routeId: text("route_id")
.notNull()
.references(() => routes.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
permission: routeSharePermission("permission").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
```
A unique constraint on `(routeId, userId)` prevents duplicate shares. If the
owner updates a share, the existing row is replaced (upsert). Deleting a share
revokes access immediately.
The table is intentionally simple: one row per user per route. No expiry, no
"pending" state. The owner shares, the recipient sees it. This matches the
architecture doc's "simple permissions" decision.
### D3: Permission functions
Create `apps/journal/app/lib/permissions.server.ts` with two core functions:
```typescript
async function canView(routeId: string, userId: string | null): Promise<boolean>;
async function canEdit(routeId: string, userId: string | null): Promise<boolean>;
```
Logic for `canView`:
1. If user is the route owner: **yes**
2. If route visibility is `public`: **yes** (even if userId is null)
3. If user has a route_share with `view` or `edit` permission: **yes**
4. Otherwise: **no**
Logic for `canEdit`:
1. If user is the route owner: **yes**
2. If user has a route_share with `edit` permission: **yes**
3. Otherwise: **no**
Note: public visibility grants view access but never edit access. Edit access
requires either ownership or an explicit `edit` share.
These functions are used in:
- **Route detail loader**: Replace the current unconditional fetch with a
`canView` check. Return 404 (not 403) for unauthorized access to avoid
revealing route existence.
- **Route list loader**: Query own routes + routes shared with the user.
Public routes are not shown in the list (they are discoverable via the
explore page in a future change).
- **Route edit action**: Check `canEdit` before allowing updates.
- **Edit-in-Planner API**: Check `canEdit` before generating a Planner session.
- **GPX download API**: Check `canView` before serving GPX.
### D4: Forking
Forking copies a route and its latest GPX into the current user's collection.
Only public routes can be forked (shared routes cannot -- the owner explicitly
chose to share, not to allow copying).
Schema addition on `journal.routes`:
```typescript
forkedFromId: text("forked_from_id").references(() => routes.id, { onDelete: "set null" }),
```
`onDelete: "set null"` means if the original route is deleted, the fork remains
but loses the provenance link. This is correct -- the fork is an independent
copy.
Fork API route (`api.routes.$id.fork.ts`):
1. Verify user is logged in
2. Load the source route, check visibility is `public`
3. Create a new route with:
- `ownerId`: current user
- `name`: same as original (user can rename later)
- `description`: same as original
- `gpx`: copy of current GPX
- `geom`: copy of current geometry
- `routingProfile`, `distance`, `elevationGain`, `elevationLoss`: copied
- `forkedFromId`: source route ID
- `visibility`: `private` (forks start private)
4. Create a v1 route version with the copied GPX
5. Redirect to the new route's detail page
UI: A "Fork" button appears on the route detail page when:
- The route is public
- The viewer is logged in
- The viewer is not the owner
The route detail page shows "Forked from [Original Route Name]" with a link
when `forkedFromId` is set and the original route still exists and is viewable.
### D5: Contributor tracking
Add a `contributors` text array column to `journal.route_versions`:
```typescript
contributors: jsonb("contributors").$type<string[]>(),
```
This stores user IDs (not usernames) of people who contributed to this version.
For now this is populated from the JWT callback: the Planner session callback
includes the JWT which identifies the route and the user who initiated the
session. When the callback saves a new version, the contributor is the user
associated with the JWT token.
In the callback handler (`api.routes.$id.callback.ts`):
1. Decode the JWT to get the user identity (already done for auth)
2. Pass the contributor user ID to the version creation function
3. Store it in the `contributors` array on the new version row
For self-saves (owner editing via Planner), the contributor is the owner. For
cross-instance edits (future federation), the contributor would be the remote
user's ActivityPub URI.
Display on the route detail page: each version in the version history shows
contributor names/usernames. The route header shows a combined list of all
unique contributors across versions.
### D6: Share dialog
The share dialog is a modal accessible from the route detail page (owner only).
It provides:
1. **User search**: A text input that searches users by username or display name.
Uses an API endpoint (`api.users.search.ts`) that returns matching users
(excluding the route owner). Search is debounced (300ms) and returns at most
10 results.
2. **Permission selector**: For each user in the share list, a dropdown with
`view` or `edit`. Defaults to `view`.
3. **Current shares**: Shows users the route is already shared with, their
permission level, and a remove button.
4. **Actions**: "Share" button adds the selected user with the chosen permission.
"Remove" button revokes a share.
The dialog uses form submissions (React Router actions) for mutations, keeping
it server-rendered and progressive. The user search uses a client-side fetch to
the search API for responsiveness.
### D7: Visibility toggle
A simple toggle on the route detail page (owner only) that switches between
private and public. Implemented as a form with a hidden intent field:
```html
<form method="post">
<input type="hidden" name="intent" value="toggleVisibility" />
<button type="submit">Make Public / Make Private</button>
</form>
```
The toggle shows the current state clearly: "This route is private" with a
"Make public" button, or "This route is public" with a "Make private" button.
Making a route private does not revoke existing shares -- shared users retain
their access. This is intentional: visibility controls anonymous access, shares
control named-user access.
The same pattern applies to activities: a visibility toggle on the activity
detail page.
### D8: Privacy manifest update
The privacy page (`apps/journal/app/routes/privacy.tsx`) must document:
- **Route visibility**: Routes and activities have a visibility setting
(private or public). Public routes are viewable by anyone with the link.
- **Route sharing**: Owners can share routes with specific users. Shared users
can see the route name, description, and GPX data according to their
permission level.
- **Forking**: Public routes can be forked (copied) by other users. The fork
is an independent copy -- changes to the fork do not affect the original and
vice versa.
- **Contributor tracking**: When you edit someone's route via the Planner, your
user ID is recorded as a contributor on that version.
## Risks / Trade-offs
- **No followers-only visibility**: Starting with just private/public is
simpler but means there is no middle ground. Users who want to share with
followers but not the world must use per-user shares. This is acceptable
until the follow system exists.
- **Fork divergence**: Forks are fully independent copies. There is no mechanism
to sync upstream changes or notify fork owners of updates. This is by design
-- forks are for adaptation, not tracking.
- **Share revocation is immediate**: Removing a share immediately revokes access.
If a shared user has the route open in a Planner session, they can finish
their current session (the JWT is still valid) but cannot start a new one.
- **User search exposes usernames**: The user search API returns usernames and
display names. This is necessary for sharing to work. Rate limiting on the
search endpoint mitigates enumeration concerns.
- **404 vs 403**: Returning 404 for unauthorized access prevents route existence
leaks but makes debugging harder for shared users who lost access. This is the
standard privacy-first approach.

View file

@ -0,0 +1,77 @@
## Why
Routes in the Journal are currently invisible to everyone except the owner. There
is no way to make a route public, share it with a specific person, or fork
someone else's route into your own collection. The architecture doc specifies
visibility levels, a permission matrix, and forking for Phase 2, and the original
`route-features` spec bundled these with spatial search, multi-day routes, and
photos. This change breaks out the sharing and permissions scope into a focused,
independent unit of work.
Without visibility and sharing, routes are just private GPX storage. Users cannot
show a planned bikepacking route to a friend, let a co-planner view the latest
version in the Journal, or build on someone else's published route. This is the
minimum social layer needed before federation makes routes visible across
instances.
## What Changes
- **Route visibility**: `private` (default) and `public` on routes and
activities. Public routes are viewable by anyone with the link. Private routes
are owner-only.
- **Route sharing**: Owners can share a route with specific users at `view` or
`edit` permission level. Shared users see the route in their collection and can
act according to their permission.
- **Route forking**: Logged-in users can fork any public route, copying the route
metadata and latest GPX into their own collection with a `forkedFromId` link
back to the original.
- **Contributor tracking**: When the Planner saves back to the Journal via JWT
callback, the contributor's identity is recorded on the route version. The
route detail page shows who contributed to each version.
These are all Journal-side changes (database schema, server logic, and UI). No
Planner changes are needed.
## Capabilities
### New Capabilities
- `route-sharing`: Visibility levels (private/public), per-user sharing with
view/edit permissions, share dialog with user search
- `route-forking`: Copy public routes into own collection with provenance link
### Modified Capabilities
- `route-management`: Visibility toggle on routes and activities, permission
checks on all route access, contributor display
- `planner-callback`: JWT callback records contributor identity on route versions
## Non-Goals
- **Granular permissions beyond view/edit**: No "edit waypoints but not
description" or "view but not export GPX". Two levels are enough.
- **Team or organization sharing**: No group-level permissions. Share with
individual users only.
- **ActivityPub federation of shares**: Sharing is local to the instance for now.
Cross-instance sharing comes with the federation change.
- **Followers-only visibility**: Only private and public for now. A
followers-only level can be added when the follow system exists.
- **Spatial search / explore page**: Broken out as a separate change. Public
routes are a prerequisite; discovery is not.
## Impact
- **Database**: New `visibility` enum column on `journal.routes` and
`journal.activities`. New `journal.route_shares` table. New `contributors`
text array on `journal.route_versions`. New `forkedFromId` column on
`journal.routes`.
- **Server logic**: New `permissions.server.ts` module with `canView` and
`canEdit` functions. Route loaders and list queries updated to respect
permissions.
- **UI**: Visibility toggle on route detail/edit page. Share dialog modal with
user search. Fork button on public routes. "Forked from" link. Contributor
list on route detail.
- **Privacy**: Privacy manifest updated to document visibility and sharing
behavior.
- **i18n**: New translation keys for visibility, sharing, forking, contributors
(en + de).

View file

@ -0,0 +1,45 @@
## 1. Schema
- [ ] 1.1 Add `visibility_enum` PostgreSQL enum (`private`, `public`) to `packages/db/src/schema/journal.ts` using `journalSchema.enum()`
- [ ] 1.2 Add `visibility` column to `journal.routes` table, type `visibility_enum`, default `private`
- [ ] 1.3 Add `visibility` column to `journal.activities` table, type `visibility_enum`, default `private`
- [ ] 1.4 Create `journal.route_shares` table with columns: `id` (text PK), `routeId` (FK to routes, cascade delete), `userId` (FK to users, cascade delete), `permission` (enum: view/edit), `createdAt`. Add unique constraint on `(routeId, userId)`
- [ ] 1.5 Add `forkedFromId` nullable text column to `journal.routes`, FK to `routes.id` with `onDelete: "set null"`
- [ ] 1.6 Add `contributors` jsonb text array column to `journal.route_versions`
- [ ] 1.7 Run `pnpm db:push` and verify schema locally
## 2. Permissions Logic
- [ ] 2.1 Create `apps/journal/app/lib/permissions.server.ts` with `canView(routeId, userId)` and `canEdit(routeId, userId)` — owner always has full access, public routes viewable by anyone, shared routes respect permission level
- [ ] 2.2 Update route detail loader (`routes.$id.tsx`) to check `canView` — return 404 for unauthorized. Pass `canEdit` result to the component for conditional UI (edit button, share button)
- [ ] 2.3 Update route list loader (`routes._index.tsx`) to include routes shared with the current user alongside owned routes
## 3. Sharing UI
- [ ] 3.1 Add visibility toggle to route detail page (owner only): form with `intent=toggleVisibility`, shows current state and toggle button
- [ ] 3.2 Create user search API route (`api.users.search.ts`): accepts `q` query param, returns matching users by username/display name (max 10, excludes requesting user), debounce-friendly
- [ ] 3.3 Create share dialog component and integrate on route detail page (owner only): user search input, permission dropdown (view/edit), current shares list with remove button. Uses form actions for add/remove share
## 4. Forking
- [ ] 4.1 Create fork API route (`api.routes.$id.fork.ts`): verify user is logged in, source route is public, copy route + latest GPX + metadata to user's collection with `forkedFromId` set, create v1 version, redirect to new route
- [ ] 4.2 Add "Fork" button on route detail page: visible when route is public, viewer is logged in, and viewer is not the owner
- [ ] 4.3 Show "Forked from [route name]" link on route detail page when `forkedFromId` is set and original route is viewable
## 5. Contributor Tracking
- [ ] 5.1 Update Planner callback handler (`api.routes.$id.callback.ts`): extract contributor identity from JWT, pass to version creation
- [ ] 5.2 Update `updateRoute` in `routes.server.ts` to accept and store `contributors` array on new route version rows
- [ ] 5.3 Display contributors on route detail page: per-version contributor names in version history, combined unique contributors in route header
## 6. Privacy & i18n
- [ ] 6.1 Update privacy manifest page (`privacy.tsx`): document route visibility settings, sharing behavior, forking, and contributor tracking
- [ ] 6.2 Add i18n keys (en + de) for: visibility labels (private/public), share dialog strings, fork button/label, contributor display, "forked from" text
## 7. Testing
- [ ] 7.1 Unit tests for `permissions.server.ts`: owner access, public access, shared view/edit access, no access, null user on public route
- [ ] 7.2 Unit tests for fork logic: successful fork of public route, reject fork of private route, forkedFromId set correctly, fork starts as private
- [ ] 7.3 E2E test: create route, toggle visibility to public, verify accessible when logged out
- [ ] 7.4 E2E test: share route with another user at view level, verify they can see it but not edit; share at edit level, verify edit access