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/route-sharing/.openspec.yaml
Normal file
2
openspec/changes/route-sharing/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
255
openspec/changes/route-sharing/design.md
Normal file
255
openspec/changes/route-sharing/design.md
Normal 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.
|
||||
77
openspec/changes/route-sharing/proposal.md
Normal file
77
openspec/changes/route-sharing/proposal.md
Normal 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).
|
||||
45
openspec/changes/route-sharing/tasks.md
Normal file
45
openspec/changes/route-sharing/tasks.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue