Propose social MVP: public-content-visibility + demo-activity-bot
Two stacked OpenSpec change proposals for a demoable social layer,
with scope deliberately minimal ("enough to send a URL and have it
open to real-looking content without signup"):
1. public-content-visibility
- Adds visibility enum {private, unlisted, public} on routes +
activities, defaulting to private for every existing row.
- Detail pages (/routes/:id, /activities/:id) become accessible to
logged-out visitors when content is public or unlisted; private
→ 404 (not 403) to avoid existence leaks.
- Broadens /users/:username into a public profile listing only
public routes + activities; 404s when there's no public content
to prevent account enumeration.
- Open Graph / Twitter Card meta on public detail + profile pages.
- Visibility selector in the owner's edit flow.
- Out of scope: follow/follower, cross-user feed, reactions,
federation.
2. demo-activity-bot (depends on #1)
- A single bot user, Bruno the trail dog, seeded on worker startup
when DEMO_BOT_ENABLED=true. Reserved username, sentinel email,
no credentials.
- pg-boss recurring job fires every 90 min, decides-to-walk with
p=0.12 during 07:00-21:00 local, yielding ~2-3 walks/day at
organic times.
- Each walk: random start + end within inner-Berlin bbox, trekking
only (dogs don't ride bikes), 2-12 km crow. BRouter plans the
route; the route GPX is also attached as the activity's trace.
- Everything inserted with visibility=public, synthetic=true.
- Daily prune deletes synthetic rows > DEMO_BOT_RETENTION_DAYS old
(default 14). Hard cap of 40 items/14d protects against runaway
growth.
- Small "🐕 demo account" badge on /users/bruno for honesty.
Both changes are artifacts only — no code lands with this PR. Apply
in order after merge: public-content-visibility first, then
demo-activity-bot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
94ce350565
commit
dcbd703f47
14 changed files with 706 additions and 0 deletions
2
openspec/changes/demo-activity-bot/.openspec.yaml
Normal file
2
openspec/changes/demo-activity-bot/.openspec.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-19
|
||||
154
openspec/changes/demo-activity-bot/design.md
Normal file
154
openspec/changes/demo-activity-bot/design.md
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
## Context
|
||||
|
||||
The Journal runs on a single Hetzner Cloud container with Postgres + PostGIS, BRouter as a sibling compose service, and pg-boss already configured as the background-job queue (`apps/journal/server.ts` boots a worker on startup). `public-content-visibility` introduces a `visibility` column on routes + activities; once that lands, a piece of content marked `public` is viewable by logged-out visitors at its permanent URL and on the owner's `/users/:username` profile.
|
||||
|
||||
This change layers a synthetic-content generator on top. The motivation is narrow — a non-empty feed for prospective users we're demoing to. The design matches that: **cheap, single-region, single-user, single-purpose**. Nothing here should generalise to "simulate a real community"; the bot is allowed to look slightly repetitive because it exists to fill an empty demo, not to fake scale.
|
||||
|
||||
Adjacent context:
|
||||
- BRouter runs internally and already rate-limits per-session at the Planner, but here we're inside the Journal server, so the bot controls its own cadence rather than sharing a limiter with user-driven routing. Load is low — at most a handful of routes per day.
|
||||
- pg-boss supports cron-style recurring schedules and singleton jobs (no concurrent duplicates), which are exactly what we need.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- A logged-out visitor landing on `trails.cool/users/demo` sees a recent-looking public profile with several routes and activities.
|
||||
- The content is generated entirely on-host, with no third-party data calls.
|
||||
- Synthetic content is trivially separable from real content (single boolean flag).
|
||||
- The bot can be disabled without a deploy (env flag) and its output wiped with a single DELETE.
|
||||
- Cadence is boring. One content item every few hours is enough — we are not trying to pretend a Strava-scale community.
|
||||
|
||||
**Non-Goals:**
|
||||
- Realistic social signals (likes, comments, follower counts).
|
||||
- Multiple bot personas or multi-region coverage.
|
||||
- Route topology tricks beyond "point-to-point via BRouter" (no multi-day, no loops, no no-go areas).
|
||||
- Photos or media on activities.
|
||||
- Running the bot in local dev / CI / staging — disabled by default everywhere except prod.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Single bot user: Bruno the trail dog
|
||||
|
||||
**Decision:** One account with:
|
||||
|
||||
- Username: `bruno` (reserved; a later real registration with that username is blocked)
|
||||
- Display name: `Bruno`
|
||||
- Bio: short, whimsical, something like *"Professional park inspector. Currently accepting tennis balls."*
|
||||
- Sentinel email: `bruno@<DOMAIN>` — unroutable, so magic-link login cannot succeed
|
||||
- No passkey credentials
|
||||
- `terms_accepted_at` / `terms_version` populated to the current values at bootstrap
|
||||
|
||||
The persona is a park-walking dog whose "human" logs the walks. This choice:
|
||||
|
||||
- Makes the bot legibly fictional at a glance — a dog account with emoji-laced names reads as *charming*, not as a real-user-you're-being-tricked-into-believing-in.
|
||||
- Narrows the generator's parameter space (trekking profile only, short distances, urban parks) so output is consistent and failure-prone edge cases get pruned.
|
||||
- Keeps the copy bank small and writable in one sitting.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **"Finn" — realistic commuter-cyclist.** Fills the feed convincingly, but deception risk if a visitor later realises. Rejected.
|
||||
- **"trails.cool test pilot" — explicitly diagnostic.** Honest but dead boring; kills the "lively feed" goal the bot exists for. Rejected.
|
||||
- **Multi-persona community simulation.** Out of scope for v1.
|
||||
|
||||
### Bruno's generation parameters
|
||||
|
||||
**Decision:** The persona constrains the generator more tightly than the generic v1 draft:
|
||||
|
||||
- **Profile pool**: `["trekking"]` only (dogs don't ride bikes).
|
||||
- **Distance band**: 2–12 km crow distance between start and end — a realistic walk, not a hike.
|
||||
- **Region**: Berlin inner (bbox roughly `13.25,52.45,13.55,52.60`), biased toward parks/green areas: Grunewald, Tiergarten, Tempelhofer Feld, Volkspark Friedrichshain, Treptower Park, Müggelsee shoreline. The bbox itself is permissive; the copy just reads as park-flavoured.
|
||||
- **Started-at window**: 07:00–20:00 local, with a slight bias toward morning/evening ("walkies" times).
|
||||
- **Copy templates**: a mix of serious-sounding audit language ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"). Bilingual EN + DE.
|
||||
|
||||
**Why narrower:** the persona is a feature, not a limitation. Not every dog-walk needs to be in a park, but pretending Bruno walked 40 km across Brandenburg would break the illusion the whole persona exists to prop up.
|
||||
|
||||
### Route generation: BRouter point-to-point in a seed region
|
||||
|
||||
**Decision:** A configured seed region is a bounding box. For the Bruno persona:
|
||||
|
||||
- Region: Berlin inner (`13.25,52.45,13.55,52.60`) — narrower than "Berlin + Brandenburg"; a dog-walk spanning Brandenburg breaks the illusion.
|
||||
- Profile: `trekking` only (see the Bruno persona decision above).
|
||||
- Start point: random within the box.
|
||||
- End point: 2–12 km crow distance from start.
|
||||
- Query BRouter with those two waypoints and `trekking`; reject and retry if BRouter returns no route or the computed distance is outside a sanity band (say, 1.5–18 km real distance).
|
||||
- Persist the returned GPX as the route's source of truth and let existing enrichment compute the rest (PostGIS geom, distance, elevation).
|
||||
|
||||
The bbox stays env-configurable; changing `DEMO_BOT_REGION` relocates Bruno without a code change.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Pre-recorded GPX files in a corpus.** Chosen by user to be (b) — synthesised via BRouter — in the conversation that led here. Keeps variety cheap and relocatable (change the bbox, new city).
|
||||
- **Komoot import** — reject, external dependency.
|
||||
- **Multi-waypoint (3+ stops)** — slight realism gain, much higher generation failure rate (more chances for BRouter to return no-route). Skip.
|
||||
|
||||
### Activity generation follows the route
|
||||
|
||||
**Decision:** When a route is generated, immediately derive an activity from it:
|
||||
|
||||
- `route_id` links to the new route
|
||||
- `name` and `description` drawn from a small templated set (e.g., "Weekend ride through the Havelland", "Evening loop around Müggelsee")
|
||||
- `started_at` = today between 06:00 and 20:00 local (random), `duration` = distance ÷ an average speed per profile ± jitter
|
||||
- `distance`, `elevation_gain`, `elevation_loss` = the route's computed values
|
||||
- `gpx` = the route's GPX verbatim (the activity "happened" along the planned route)
|
||||
- `visibility = 'public'`, `synthetic = true`
|
||||
|
||||
**Why bundled:** keeping route + activity generation in one transaction means the feed item is coherent the moment it appears. Separating them just to pretend the user planned then rode feels like theatre — we're not hiding the bot, we're just filling the surface.
|
||||
|
||||
### Cadence and guards
|
||||
|
||||
**Decision:** A dog walks twice a day, sometimes three times, never six. The schedule should look like Bruno's day, not like a cron job.
|
||||
|
||||
- A recurring pg-boss job `demo-bot:generate` fires **every 90 minutes** as a singleton.
|
||||
- Most ticks decide to *not* walk: the handler rolls a per-tick probability and skips if it doesn't hit. The probability is **0.12 per tick during 07:00–21:00 local, 0 otherwise** — which yields roughly 2–3 walks per day across the day, biased to waking hours.
|
||||
- This both looks human (walks aren't on a fixed cron) and makes the generation load tiny: one BRouter call per walk, so ~2–3 per day.
|
||||
- The job skips itself entirely when `process.env.DEMO_BOT_ENABLED !== "true"`. Dev / CI / tests: no-op.
|
||||
- Hard cap: if there are already ≥ 40 synthetic items in the last 14 days (≈ 3/day × 14 rounded up), skip for that tick. Stops runaway growth if the retention job fails.
|
||||
- BRouter call per generation: 1. Bot traffic is rounding-error compared to real user traffic.
|
||||
|
||||
**Alternatives considered:**
|
||||
- **Fixed cron every 4 hours** — boring, too regular, a demo visitor with sharp eyes notices.
|
||||
- **Poisson process with mean 3/day** — more faithful but more code. The 90-minute-tick-with-probability scheme is close enough.
|
||||
- **Walk at specific realistic times** (morning, lunch, evening) — adds temporal pattern. Probably worth revisiting once we see the feed in practice, but not worth coding up front.
|
||||
|
||||
### Retention
|
||||
|
||||
**Decision:** A second recurring job `demo-bot:prune` runs daily and deletes synthetic routes + activities whose `created_at` is older than `DEMO_BOT_RETENTION_DAYS` (default 14). Route-version rows cascade-delete via existing FK.
|
||||
|
||||
**Why daily, not weekly:** smaller blast radius if the prune gets something wrong, and it keeps the feed visibly "recent" rather than stable.
|
||||
|
||||
### Flagging synthetic content at the DB level
|
||||
|
||||
**Decision:** Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` and `journal.activities`. Set `true` only when the bot inserts.
|
||||
|
||||
**Why:** makes the "delete all bot content" operation a one-liner and lets future listing code exclude synthetic if we decide to, without introspecting content shape or owner. `owner_id = demo_user_id` would almost work as a proxy, but the dedicated flag decouples identity from status — if we later delete and re-seed the demo user we don't lose the signal.
|
||||
|
||||
### Env surface
|
||||
|
||||
**Decision:**
|
||||
- `DEMO_BOT_ENABLED`: `"true"` turns the generator + prune on; anything else is off. Absent means off.
|
||||
- `DEMO_BOT_RETENTION_DAYS`: integer, defaults to 14.
|
||||
- `DEMO_BOT_REGION`: JSON `{ "bbox": [w,s,e,n] }`, defaults to inner Berlin (`13.25,52.45,13.55,52.60`) to suit Bruno's park-walker persona.
|
||||
- No secrets. The whole feature is public-facing by design.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **BRouter outages halt the feed** → acceptable; the job logs and skips. The feed will stop refreshing but existing items remain visible.
|
||||
- **Uncanny repetition** (same start neighbourhoods, template names) → mitigated by varying the start point randomly and the templated copy, but still acceptable for a demo. A reviewer will obviously figure out it's synthetic if they look — we're not hiding it.
|
||||
- **Mistaking bot content for real content during analytics** → mitigated by the `synthetic` flag; analytics queries filter `WHERE synthetic = false` when they want real usage.
|
||||
- **Runaway insert if retention breaks** → mitigated by the hard cap (50 items in last 14 days) inside the generator.
|
||||
- **GDPR / privacy concerns** → none new: the bot has no real PII, its content is first-party, and `demo@trails.cool` is a reserved sentinel address.
|
||||
- **Someone logs in as `demo`** → the user has no credentials (no passkey, no magic-token). Email sentinel means a magic-link request can't succeed either (no inbox). Safe.
|
||||
- **Accidentally enabled in dev** → mitigated by the explicit env opt-in; default-off in every environment but prod.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Merge schema + code; `drizzle-kit push --force` adds `synthetic` columns.
|
||||
2. Set `DEMO_BOT_ENABLED=true` on the prod Journal container; leave every other environment off.
|
||||
3. On next worker restart, the bootstrap step inserts the `demo` user.
|
||||
4. First generation run produces one route + activity. Verify at `/users/demo`.
|
||||
5. After a few ticks, verify the feed looks plausible and the prune doesn't fire unexpectedly (it won't, nothing is 14 days old).
|
||||
|
||||
**Rollback:** `DEMO_BOT_ENABLED=false` and redeploy. To wipe all generated content: `DELETE FROM journal.activities WHERE synthetic = true; DELETE FROM journal.routes WHERE synthetic = true;`. The `demo` user row can stay — it's cheap and keeps the URL stable if we re-enable later.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Do we want the profile to also get a "synthetic content" badge** so honest readers (and us, on demos) can tell at a glance? A tiny "demo account" pill on `/users/demo` is cheap. Decide during implementation.
|
||||
- **Should the bot post a few backfilled items on first enablement** so the feed isn't just one item for the first four hours? Probably yes — a small one-time bootstrap that generates 3–5 items immediately if the synthetic-item count is 0. Proposing it as a task.
|
||||
- **Should BRouter calls be recorded as `brouter_request_duration_seconds` the same as user requests**? Probably — same histogram is fine; synthetic traffic is a small addition and it keeps ops alerts meaningful.
|
||||
35
openspec/changes/demo-activity-bot/proposal.md
Normal file
35
openspec/changes/demo-activity-bot/proposal.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
## Why
|
||||
|
||||
To demo trails.cool to prospective users and contributors we need live-looking content on the public surface introduced by `public-content-visibility`. Today a demo link opens to an empty profile. Even after visibility ships, the Journal has 3 users and 0 activities — the feed shows "nothing happened today," which is the worst possible first impression.
|
||||
|
||||
A synthetic "demo" user that autonomously produces plausible activities closes that gap. It also keeps pressure on the content-creation code paths: if the bot is wedged, the import / routing / geometry pipelines are probably broken for real users too.
|
||||
|
||||
This change is the direct follow-up to `public-content-visibility` and is only useful once that lands.
|
||||
|
||||
## What Changes
|
||||
|
||||
- A dedicated bot user account (username e.g. `demo`) is bootstrapped on first run with a stable ID, a public display name, and `visibility` defaults that mark its content public.
|
||||
- A recurring pg-boss job (the existing background-jobs infrastructure on the Journal host) runs every few hours, chooses a random start + end point within a configured seed region, asks BRouter for a route, persists the route + a derived activity attributed to the demo user with `visibility = 'public'`.
|
||||
- Synthetic rows are explicitly flagged at the database level (`synthetic boolean NOT NULL DEFAULT false`) so a single DELETE can clean them up and listings can optionally exclude them later.
|
||||
- A retention job trims synthetic activities older than a configurable window (default 14 days) so the feed looks fresh, not a swamp of old rides.
|
||||
- Everything is gated behind a `DEMO_BOT_ENABLED` env flag. Local dev, CI, and tests default to disabled. The flag is set on prod only.
|
||||
- Route profiles vary: trekking and fastbike, with plausible distance ranges for each. Start times of day look reasonable. Names and descriptions draw from a small templated copy set (in both EN and DE).
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `demo-activity-bot`: A bot user, a scheduler that seeds plausible public routes + activities using BRouter and a seed region, and a retention job to keep the feed bounded.
|
||||
|
||||
### Modified Capabilities
|
||||
- `route-management`: adds a `synthetic` flag alongside the `visibility` work from `public-content-visibility`, so listings and exports can distinguish bot content if they ever need to.
|
||||
- `activity-feed`: same `synthetic` flag for symmetry.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code**: new `apps/journal/app/jobs/demo-bot.ts` for the job handlers, small helpers for picking seed coordinates and templating names, a one-time bootstrap that inserts the demo user if missing.
|
||||
- **Data**: a schema change (the `synthetic` column on two tables) plus ongoing inserts into those same tables. Bounded by the retention job.
|
||||
- **Infrastructure**: no new services — pg-boss is already in the stack. `DEMO_BOT_ENABLED` is a new env var on the prod Journal container.
|
||||
- **External calls**: BRouter calls from the bot; throttled at the scheduler level so the bot never competes with real user traffic. No external API beyond BRouter.
|
||||
- **Privacy**: the bot user has no real email, no PII, and its content is clearly author-attributed to `demo`. The legal / privacy pages do not need to change — this is first-party content the operator controls.
|
||||
- **Non-goals (explicit)**: realistic social signals (no likes/comments on demo content), multi-user bot personas (one user for v1), cross-region variety (one seed region), multi-day tours, photos or media attachments.
|
||||
- **Rollback**: flip `DEMO_BOT_ENABLED=false` — the job stops. A single `DELETE FROM ... WHERE synthetic = true` removes all bot content without touching real data.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Synthetic activity flag
|
||||
The Journal SHALL persist a `synthetic` boolean on every activity so automated / demo content can be distinguished from user-created content.
|
||||
|
||||
#### Scenario: User-created activities default to non-synthetic
|
||||
- **WHEN** an activity is created through any user-facing flow (GPX upload, sync import, Planner handoff)
|
||||
- **THEN** the activity row is persisted with `synthetic = false`
|
||||
|
||||
#### Scenario: Bot inserts flag their rows as synthetic
|
||||
- **WHEN** the demo-activity-bot inserts an activity
|
||||
- **THEN** the row is persisted with `synthetic = true`
|
||||
|
||||
#### Scenario: Synthetic flag is not user-editable
|
||||
- **WHEN** an activity owner edits the activity via any user-facing action
|
||||
- **THEN** the stored `synthetic` value is not changed by the edit
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Demo user bootstrap
|
||||
The Journal SHALL ensure a dedicated bot user exists when the demo bot starts, creating it on first run if missing.
|
||||
|
||||
#### Scenario: Bot user created on first run
|
||||
- **WHEN** the Journal worker starts with `DEMO_BOT_ENABLED=true` and no `users` row matches the reserved demo username
|
||||
- **THEN** a new `users` row is inserted with the reserved username, a public-facing display name, a sentinel email (`demo@<domain>`), no passkey credentials, and `terms_accepted_at` + `terms_version` populated at the current version
|
||||
- **AND** subsequent worker startups are idempotent — no second row is inserted
|
||||
|
||||
#### Scenario: Demo user has no usable credentials
|
||||
- **WHEN** any request attempts to authenticate as the demo user via passkey or magic-link
|
||||
- **THEN** authentication fails because no passkey is registered and no mailbox receives magic-link mails
|
||||
|
||||
### Requirement: Synthetic content generation job
|
||||
The Journal SHALL run a recurring background job that generates one public route and one linked public activity for the demo user per run, subject to an env flag and a rate cap.
|
||||
|
||||
#### Scenario: Disabled in non-production environments
|
||||
- **WHEN** the `DEMO_BOT_ENABLED` env var is absent or any value other than `"true"`
|
||||
- **THEN** the job body is a no-op: no BRouter calls, no inserts, no errors
|
||||
|
||||
#### Scenario: Enabled generation flow (decide-to-walk fires)
|
||||
- **WHEN** `DEMO_BOT_ENABLED=true`, the local hour is within 07:00–21:00, the per-tick Bernoulli roll fires, and the hard cap has not been reached
|
||||
- **THEN** the job picks a random start and end point within the configured seed region, calls BRouter with the `trekking` profile, persists the returned GPX as a new route with `visibility='public'` and `synthetic=true`, and inserts a linked activity with the same GPX, `visibility='public'`, `synthetic=true`, a plausible `started_at` and `duration`, and a templated Bruno-voiced name/description
|
||||
- **AND** the route and activity are attributed to the demo user
|
||||
|
||||
#### Scenario: Decide-to-walk does not fire
|
||||
- **WHEN** the local hour is outside 07:00–21:00, or the Bernoulli roll does not fire
|
||||
- **THEN** the job returns without inserting anything — Bruno just isn't walking right now
|
||||
|
||||
#### Scenario: BRouter failure is tolerated
|
||||
- **WHEN** the BRouter call returns no route, a rate-limit, or an error
|
||||
- **THEN** the job logs the failure, inserts nothing, and exits without throwing — the next scheduled tick retries
|
||||
|
||||
#### Scenario: Hard cap prevents runaway growth
|
||||
- **WHEN** there are already 40 or more synthetic items created in the last 14 days
|
||||
- **THEN** the job skips generation for that tick
|
||||
|
||||
#### Scenario: Singleton scheduling prevents overlap
|
||||
- **WHEN** a tick fires while the previous run is still executing
|
||||
- **THEN** the new tick is skipped (pg-boss singleton semantics) so the job cannot overlap itself
|
||||
|
||||
### Requirement: Synthetic content retention
|
||||
The Journal SHALL run a recurring job that deletes synthetic routes and activities older than a configurable window.
|
||||
|
||||
#### Scenario: Prune removes old synthetic content
|
||||
- **WHEN** the prune job runs with `DEMO_BOT_RETENTION_DAYS=14`
|
||||
- **THEN** every row in `journal.routes` and `journal.activities` with `synthetic=true` and `created_at < now() - 14 days` is deleted
|
||||
- **AND** route-version rows cascade-delete via existing foreign keys
|
||||
- **AND** rows with `synthetic=false` are never touched
|
||||
|
||||
#### Scenario: Prune is a no-op when nothing is old
|
||||
- **WHEN** the prune job runs and no synthetic rows exceed the retention window
|
||||
- **THEN** no DELETE statements execute and the job returns normally
|
||||
|
||||
#### Scenario: Disabled in non-production environments
|
||||
- **WHEN** `DEMO_BOT_ENABLED` is not `"true"`
|
||||
- **THEN** the prune job body is a no-op
|
||||
|
||||
### Requirement: Initial backfill
|
||||
On first enablement (when no synthetic content exists yet) the Journal SHALL populate the demo profile with a small batch of items so the first visitor does not see a single-item feed.
|
||||
|
||||
#### Scenario: First enablement backfills several items
|
||||
- **WHEN** the bot is enabled for the first time and the count of synthetic routes is 0
|
||||
- **THEN** the generation job produces 3–5 items in sequence during its first run
|
||||
- **AND** subsequent runs produce one item at a time as normal
|
||||
|
||||
### Requirement: Seed region is configurable
|
||||
The Journal SHALL read the seed region (bounding box) from an env var so the deployment can change it without a code change.
|
||||
|
||||
#### Scenario: Env-configured region
|
||||
- **WHEN** `DEMO_BOT_REGION` is set to a JSON object containing a `bbox` array of four numbers `[west, south, east, north]`
|
||||
- **THEN** all generated start and end points fall within that box
|
||||
|
||||
#### Scenario: Sensible default
|
||||
- **WHEN** `DEMO_BOT_REGION` is unset
|
||||
- **THEN** the job uses a documented default region (inner Berlin) so that out-of-the-box runs still produce plausible Bruno-style walks
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Synthetic route flag
|
||||
The Journal SHALL persist a `synthetic` boolean on every route so automated / demo content can be distinguished from user-created content.
|
||||
|
||||
#### Scenario: User-created routes default to non-synthetic
|
||||
- **WHEN** a route is created through any user-facing flow (New Route, GPX import, Planner handoff)
|
||||
- **THEN** the route row is persisted with `synthetic = false`
|
||||
|
||||
#### Scenario: Bot inserts flag their rows as synthetic
|
||||
- **WHEN** the demo-activity-bot inserts a route
|
||||
- **THEN** the row is persisted with `synthetic = true`
|
||||
|
||||
#### Scenario: Synthetic flag is not user-editable
|
||||
- **WHEN** a route owner edits a route via any user-facing action
|
||||
- **THEN** the stored `synthetic` value is not changed by the edit
|
||||
72
openspec/changes/demo-activity-bot/tasks.md
Normal file
72
openspec/changes/demo-activity-bot/tasks.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
## 1. Schema
|
||||
|
||||
- [ ] 1.1 Add `synthetic boolean NOT NULL DEFAULT false` to `journal.routes` in `packages/db/src/schema/journal.ts`
|
||||
- [ ] 1.2 Add the same column to `journal.activities`
|
||||
- [ ] 1.3 Verify existing CRUD helpers (`createRoute`, `updateRoute`, `createActivity`, `listRoutes`, `listActivities`) compile and ignore the new column for default callers
|
||||
|
||||
## 2. Bruno bootstrap
|
||||
|
||||
- [ ] 2.1 Reserved username: `bruno`. Sentinel email: `bruno@<DOMAIN>`. Display name: `Bruno`. Bio: a short whimsical line (finalise exact wording during apply — e.g. "Professional park inspector. Currently accepting tennis balls.").
|
||||
- [ ] 2.2 Add an `ensureDemoUser()` helper in `apps/journal/app/lib/demo-bot.server.ts` that inserts the `bruno` row only if missing (idempotent via `ON CONFLICT DO NOTHING` on `username`), with `terms_accepted_at` and `terms_version` populated.
|
||||
- [ ] 2.3 Call `ensureDemoUser()` from the worker startup sequence when `DEMO_BOT_ENABLED === "true"`.
|
||||
- [ ] 2.4 Confirm Bruno has no credentials row; the passkey and magic-link login paths both fail for his email (no mailbox exists for `bruno@<DOMAIN>`).
|
||||
|
||||
## 3. Region + generation helpers
|
||||
|
||||
- [ ] 3.1 Add `loadRegion()` that reads `DEMO_BOT_REGION` JSON (with `bbox: [w,s,e,n]`) and falls back to the inner-Berlin default `[13.25, 52.45, 13.55, 52.60]`
|
||||
- [ ] 3.2 Add `pickEndpoints(bbox)` — random start + end, 2–12 km crow distance apart (dog-walk scale)
|
||||
- [ ] 3.3 Profile is fixed to `trekking` (Bruno doesn't ride bikes); no `pickProfile()` needed for v1
|
||||
- [ ] 3.4 Add `templateName(startedAt)` + `templateDescription()` — EN + DE Bruno-voiced copy, mixing serious-audit flavour ("Grunewald north-loop patrol") and comic ("Bruno found three sticks today 🐕"); 10–15 entries in each pool, picked deterministically from the day + started-at to avoid same-day repeats
|
||||
|
||||
## 4. BRouter client for the bot
|
||||
|
||||
- [ ] 4.1 Reuse the existing `computeRoute` helper where possible; otherwise add a minimal `brouter-client.server.ts` that POSTs to `process.env.BROUTER_URL` and returns parsed GPX + stats
|
||||
- [ ] 4.2 On no-route / 5xx / timeout, return a typed "no route" error — the job handles it, does not throw out of the worker
|
||||
|
||||
## 5. Generation job
|
||||
|
||||
- [ ] 5.1 Register a pg-boss recurring job `demo-bot:generate` to fire every 90 minutes, singleton mode
|
||||
- [ ] 5.2 Handler: skip immediately if `DEMO_BOT_ENABLED !== "true"`
|
||||
- [ ] 5.3 Decide-to-walk gate: compute local hour; if outside 07:00–21:00 → skip. Otherwise roll a Bernoulli with p=0.12 and skip if it doesn't fire. Expected output: ~2–3 walks/day
|
||||
- [ ] 5.4 Hard cap: `SELECT count(*) FROM routes WHERE synthetic AND created_at > now() - interval '14 days'`; skip if >= 40
|
||||
- [ ] 5.5 Pick region + endpoints (profile is fixed to `trekking`); call BRouter; on failure log and return
|
||||
- [ ] 5.6 Insert route with `visibility='public'`, `synthetic=true`, owner = Bruno; insert linked activity with derived `started_at` = now, `duration` = distance ÷ ~4.5 km/h ± jitter, stats, and the same GPX
|
||||
|
||||
## 6. Initial backfill
|
||||
|
||||
- [ ] 6.1 At the top of the generation handler, check if `count(synthetic routes) == 0`; if so, loop up to 5 times running the full generation body sequentially (each call independent — if one fails, keep going)
|
||||
- [ ] 6.2 After the backfill path, fall through to the normal single-item generation
|
||||
|
||||
## 7. Retention job
|
||||
|
||||
- [ ] 7.1 Register a pg-boss recurring job `demo-bot:prune` with a cron like `15 3 * * *` (daily at 03:15 UTC), singleton
|
||||
- [ ] 7.2 Handler: skip if `DEMO_BOT_ENABLED !== "true"`
|
||||
- [ ] 7.3 Read `DEMO_BOT_RETENTION_DAYS` with default 14
|
||||
- [ ] 7.4 `DELETE FROM journal.activities WHERE synthetic AND created_at < now() - interval ?`; same for routes
|
||||
- [ ] 7.5 Log the count removed for observability
|
||||
|
||||
## 8. Ops + env surface
|
||||
|
||||
- [ ] 8.1 Document `DEMO_BOT_ENABLED`, `DEMO_BOT_RETENTION_DAYS`, `DEMO_BOT_REGION` in `infrastructure/secrets.app.env`'s commented template (do not enable in any environment file except prod)
|
||||
- [ ] 8.2 Set `DEMO_BOT_ENABLED=true` only in prod
|
||||
- [ ] 8.3 Expose counts via a Prometheus gauge (e.g. `demo_bot_synthetic_routes_total`, `demo_bot_synthetic_activities_total`) and reuse the existing `brouter_request_duration_seconds` histogram for the bot's BRouter calls — no new metric plumbing needed
|
||||
|
||||
## 9. UX tweaks
|
||||
|
||||
- [ ] 9.1 "🐕 demo account" badge on `/users/bruno` so honest readers can tell at a glance; gate on `user.username === 'bruno'`
|
||||
- [ ] 9.2 i18n: `demo.badge` key in EN + DE (e.g. "Demo account" / "Demo-Konto")
|
||||
|
||||
## 10. Tests
|
||||
|
||||
- [ ] 10.1 Unit: `ensureDemoUser` is idempotent; second call is a no-op and does not duplicate
|
||||
- [ ] 10.2 Unit: `pickEndpoints` stays within bbox; distance bands match profile
|
||||
- [ ] 10.3 Unit: `templateName` / `templateDescription` return non-empty strings in both locales
|
||||
- [ ] 10.4 Integration (tests DB): the generation handler inserts one route + one activity with `synthetic=true, visibility='public'` when enabled, and inserts nothing when disabled
|
||||
- [ ] 10.5 Integration: the prune handler deletes only `synthetic=true` rows past the window; real rows are untouched
|
||||
|
||||
## 11. Rollout
|
||||
|
||||
- [ ] 11.1 Merge schema + code; deploy — the bot stays disabled everywhere because no env flag is set
|
||||
- [ ] 11.2 Flip `DEMO_BOT_ENABLED=true` on prod via the SOPS env file + redeploy
|
||||
- [ ] 11.3 On next worker start: verify `ensureDemoUser` created the `demo` user, the backfill produced 3–5 items, and `/users/demo` renders them publicly
|
||||
- [ ] 11.4 After 24 h: verify cadence looks right and the prune job ran without deleting anything yet
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-04-19
|
||||
111
openspec/changes/public-content-visibility/design.md
Normal file
111
openspec/changes/public-content-visibility/design.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
## Context
|
||||
|
||||
The Journal uses a straightforward "logged-in or you're not seeing anything" access model today. Routes and activities are owned by a single user via `owner_id` foreign keys, and every detail-page loader calls `getSessionUser(request)` then either serves the page or (implicitly) returns the "route not found" error. There is no notion of cross-user visibility.
|
||||
|
||||
The Planner is separately anonymous, so that's not a concern here — this change is Journal-only.
|
||||
|
||||
Two near-term pressures shape the design:
|
||||
|
||||
- **The demo plan** (send a URL to a prospective user or contributor and have them see real content without signing up). Anything that requires a friction step on arrival defeats the demo.
|
||||
- **Upcoming `demo-activity-bot`** that generates synthetic activities. That content has to be publicly viewable or the bot is pointless.
|
||||
|
||||
What this design deliberately *does not* touch:
|
||||
- Followers / subscriptions / per-user feeds
|
||||
- Reactions / comments / any write surface for non-owners
|
||||
- ActivityPub / Fedify / federation
|
||||
- Per-segment privacy on a single route
|
||||
|
||||
Those are all future chapters. Keeping the first step narrow means less to get wrong and less to re-design later.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- A route or activity owner can mark a piece of content as public.
|
||||
- Logged-out visitors can open that content at its permanent URL.
|
||||
- A logged-out visitor can find the rest of that user's public content via `/users/:username`.
|
||||
- Sharing a public URL on Slack / Twitter / Bluesky / email produces a decent preview.
|
||||
- Default stays private — nothing already in the database becomes visible just because this change ships.
|
||||
|
||||
**Non-Goals:**
|
||||
- Discovery (there is no "browse public routes" page on the instance yet).
|
||||
- Indexing control beyond the default (no per-route `robots` override).
|
||||
- Changing the Planner's anonymous model.
|
||||
- Any write access for non-owners on public content.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Visibility model: three values, not two
|
||||
|
||||
**Decision:** `visibility` enum `'private' | 'unlisted' | 'public'`. Stored as a text column for simplicity; can be migrated to a native enum later if worth it.
|
||||
|
||||
- `private` (default) — only the owner can fetch the detail page; listings never show it.
|
||||
- `unlisted` — anyone with the URL can view; excluded from the owner's public profile and any future discovery pages.
|
||||
- `public` — anyone can view; appears on the owner's public profile.
|
||||
|
||||
**Alternatives:**
|
||||
- **Just `private | public`** — simpler, but kills the useful "I want to share with someone without putting it on my profile" case. Every other sharing-oriented service has an unlisted tier; it's cheap to add now and irritating to retrofit later.
|
||||
- **Sharing via time-limited tokens** (Google-Docs-style) — more powerful but out of scope for the demo goal. Parked.
|
||||
|
||||
### Default is `private` for all existing rows
|
||||
|
||||
**Decision:** The new column ships with `DEFAULT 'private'` and every existing row — two real routes and zero activities — remains private. Users opt in to publication by explicitly changing the value.
|
||||
|
||||
**Why:** This change cannot silently un-privatise data anyone uploaded under the old rules. That principle outweighs the demo inconvenience (we'll mark the demo user's seeded content public at insert time).
|
||||
|
||||
### Access check lives in route loaders
|
||||
|
||||
**Decision:** Each detail-page loader (`routes.$id.tsx`, `activities.$id.tsx`) resolves the content row, compares `visibility` + `owner_id` against the session user:
|
||||
|
||||
- `visibility === 'public'` → serve.
|
||||
- `visibility === 'unlisted'` AND the request is a direct detail page URL → serve.
|
||||
- otherwise → serve only if the requester is the owner, else 404 (not 403 — leaking existence is a privacy bug).
|
||||
|
||||
A small helper `canView(content, user)` in `~/lib/auth.server` centralises the rule so list endpoints use the same logic.
|
||||
|
||||
**Alternatives:**
|
||||
- **Route-level middleware** (before loaders) — cleaner in theory but React Router 7 loaders are the right place for content-specific auth, and centralising via a helper avoids coupling the router tree to visibility semantics.
|
||||
|
||||
### Public profile page reuses the existing `/users/:username` route
|
||||
|
||||
**Decision:** That route exists today but is auth-gated (and renders the current user's own routes/activities). Broaden it:
|
||||
|
||||
- Publicly accessible.
|
||||
- Shows public routes + public activities of the requested username, most recent first.
|
||||
- 404 if the user has no public content at all. This hides the existence of private-only accounts; an attacker can't enumerate accounts by username.
|
||||
- A "This is your profile" control strip visible only to the logged-in owner, with a quick link to settings.
|
||||
|
||||
### Open Graph / Twitter Card meta tags
|
||||
|
||||
**Decision:** Each public route / activity detail page emits OG tags via React Router's `meta` export:
|
||||
|
||||
- `og:title` — route or activity name + "· trails.cool"
|
||||
- `og:description` — user description (truncated) or a sensible default
|
||||
- `og:type` — `"article"`
|
||||
- `og:site_name` — `"trails.cool"`
|
||||
- `twitter:card` — `"summary"` (summary, not summary_large_image; we don't have social cards yet)
|
||||
|
||||
A future `social-preview-images` follow-up can add rendered static map PNGs. Explicitly out of scope here.
|
||||
|
||||
### `robots` control
|
||||
|
||||
**Decision:** Public route / activity pages emit no `robots` meta (defaults to indexable). The legal pages keep their existing `noindex`. No sitemap is generated; discovery via search is passive.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Username enumeration via profile 404** → Mitigation: 404 both for "no such user" and "user exists but has no public content", so the two are indistinguishable. Timing-side-channel on the DB lookup is not defended against in v1; acceptable at this scale.
|
||||
- **Future listing pages will want more data** → The `canView` helper already supports it. Adding an `index` boolean or "discoverable" flag is additive.
|
||||
- **`unlisted` is only private-ish** → If the URL leaks it's effectively public. That's the Internet; same as every other unlisted-sharing feature. Documented in the settings UI copy.
|
||||
- **Content previously private becomes linkable** → Once a user makes a route public and shares it, they can't fully retract — archives exist. Same as any web publish. No new warning for v1; the visibility selector itself is unambiguous.
|
||||
- **Spec drift risk** → `route-management` and `activity-feed` both gain modified requirements; keep the delta specs tight so the archive survives review.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Merge schema change; `drizzle-kit push --force` on deploy adds the column with default `'private'`.
|
||||
2. Logged-out access to public pages is a new code path — nothing existing regresses since all rows remain `'private'` until a user changes them.
|
||||
3. `demo-activity-bot` is the first caller that will create rows with `visibility: 'public'` at insert time.
|
||||
4. Rollback: `UPDATE routes SET visibility = 'private'; UPDATE activities SET visibility = 'private';` restores the prior behaviour without any code change; the public code path becomes dead but harmless.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the settings page add a default-visibility preference per user (e.g. "make new routes public by default")? Probably nice but not in this spec; revisit after observing real usage.
|
||||
- Do we want to emit `<link rel="canonical">` on unlisted pages to discourage indexing of the URL if it leaks? Low-cost, worth considering during implementation.
|
||||
37
openspec/changes/public-content-visibility/proposal.md
Normal file
37
openspec/changes/public-content-visibility/proposal.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
## Why
|
||||
|
||||
trails.cool currently has no way to show its content to anyone who isn't logged in. Route and activity detail pages require auth, there is no public-facing profile page, and shared URLs have no Open Graph metadata — so a link pasted in Slack or on a social platform looks like a blank trails.cool card.
|
||||
|
||||
Two immediate reasons to fix this:
|
||||
|
||||
1. **Demos + acquisition.** The next phase of the project is onboarding more users and contributors. The pitch lands better when you can send a URL that opens directly to "look at this route / this weekend's ride" without making the visitor register first.
|
||||
2. **Prerequisite for any `demo-activity-bot` work.** A synthetic-activity generator is pointless if the generated content isn't publicly viewable. This change unblocks that follow-up.
|
||||
|
||||
This is also the smallest possible step into the "social" direction the Journal was always described as heading for. It does not introduce follow/follower, cross-user feeds, reactions, or ActivityPub — those remain future work.
|
||||
|
||||
## What Changes
|
||||
|
||||
- New `visibility` column on routes and activities with values `private` (default for existing rows), `unlisted` (reachable only by direct URL, excluded from listings), `public` (visible everywhere).
|
||||
- Logged-out visitors can view **public** route and activity detail pages. Private / nonexistent routes return 404, just like logged-in requests to other users' private content. Unlisted renders for anyone with the URL but does not appear in listings.
|
||||
- New public profile page at `/users/:username` that lists that user's **public** routes and activities. Returns 404 if the user has no public content (keeps the existence of private-only accounts itself private).
|
||||
- Route detail page gains a visibility selector for owners (the existing edit page is the natural home).
|
||||
- Open Graph / Twitter Card meta tags on public route and activity detail pages so shared URLs preview nicely (title, description, a small route preview image for future, the instance name as site).
|
||||
- **BREAKING for spec-readers only**: several existing requirements in `route-management` and `activity-feed` are modified to reflect the new "auth-or-public" access rule. The default in migration is `private`, so **behaviour for existing users does not change** — their routes stay inaccessible to logged-out visitors until they explicitly make one public.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `public-profiles`: Public-facing user pages at `/users/:username` listing a user's public routes and activities, with nothing shown for users who have no public content.
|
||||
|
||||
### Modified Capabilities
|
||||
- `route-management`: adds visibility field + modified access rules so public routes are viewable without auth; owners can change visibility.
|
||||
- `activity-feed`: adds visibility field; public activities render to logged-out visitors.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code**: schema (`visibility` column on `routes` + `activities`), loader auth changes on `routes.$id.tsx` / `activities.$id.tsx`, new route `users.$username.tsx` (already exists but is auth-gated today), a visibility selector somewhere in the route edit flow, a `meta` export on public detail pages for OG tags.
|
||||
- **Data**: default `visibility = private` for all existing rows — three users, zero activities, two empty routes — so migration is effectively a no-op for behaviour. No backfill needed.
|
||||
- **Privacy**: this change explicitly reduces the default privacy posture *only if a user opts in by marking content public*. A short sentence in `/legal/privacy` notes public content is world-visible. No new third-party surface.
|
||||
- **Search engines**: public pages are not actively promoted (no sitemap). `robots: noindex` stays on legal pages and settings; `routes` and `activities` public pages get a permissive default (search-indexable) so demo URLs work well.
|
||||
- **Non-goals (explicit)**: follow/follower, a cross-user feed, reactions/comments, federation, route privacy per-section. Keeping scope tight.
|
||||
- **Rollback**: flipping the default back to `private` everywhere is a single UPDATE. Pages gating on visibility degrade to "auth-only" if we remove the public path.
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Activity detail page
|
||||
The Journal SHALL display an activity detail page with map, stats, and description. Access depends on the activity's `visibility`: `public` activities are viewable by anyone including unauthenticated visitors, `unlisted` activities are viewable by anyone who has the URL, and `private` activities are viewable only by the owner.
|
||||
|
||||
#### Scenario: Owner views own activity
|
||||
- **WHEN** a logged-in user navigates to an activity they own at any visibility
|
||||
- **THEN** they see the activity name, description, a map with the GPS trace, distance, duration, and elevation stats
|
||||
|
||||
#### Scenario: Anyone views a public activity
|
||||
- **WHEN** any visitor (including unauthenticated) navigates to a `public` activity's URL
|
||||
- **THEN** they see the full activity detail page as above
|
||||
|
||||
#### Scenario: Anyone with the URL views an unlisted activity
|
||||
- **WHEN** any visitor navigates directly to an `unlisted` activity's URL
|
||||
- **THEN** they see the full activity detail page as above
|
||||
|
||||
#### Scenario: Non-owner is blocked from a private activity
|
||||
- **WHEN** a visitor who is not the owner requests a `private` activity URL
|
||||
- **THEN** the server responds with HTTP 404 (not 403), so the existence of the private activity is not leaked
|
||||
|
||||
#### Scenario: Public and unlisted activity pages emit social-share metadata
|
||||
- **WHEN** a visitor loads a `public` or `unlisted` activity detail page
|
||||
- **THEN** the response emits Open Graph and Twitter Card meta tags (`og:title`, `og:description`, `og:type="article"`, `og:site_name`, `twitter:card="summary"`)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Activity visibility
|
||||
The Journal SHALL persist a `visibility` value on every activity and SHALL allow the owner to change it.
|
||||
|
||||
#### Scenario: New activities default to private
|
||||
- **WHEN** an activity is created without an explicit visibility
|
||||
- **THEN** the activity row is persisted with `visibility = 'private'`
|
||||
|
||||
#### Scenario: Owner changes an activity's visibility
|
||||
- **WHEN** an activity owner selects a different visibility (`private`, `unlisted`, `public`) and saves
|
||||
- **THEN** the stored visibility is updated and subsequent access checks use the new value immediately
|
||||
|
||||
### Requirement: Activity listings respect visibility
|
||||
The Journal's own-activities feed SHALL show the owner everything regardless of visibility, while any cross-user listing SHALL only include activities with `visibility = 'public'`.
|
||||
|
||||
#### Scenario: Own activity feed is unchanged
|
||||
- **WHEN** a logged-in user views their own activity feed
|
||||
- **THEN** the feed includes all of their own activities regardless of visibility
|
||||
|
||||
#### Scenario: Public profile lists only public activities
|
||||
- **WHEN** a visitor loads `/users/:username`
|
||||
- **THEN** the rendered list of activities includes only the user's `public` activities; `unlisted` and `private` activities are omitted
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Public profile page
|
||||
The Journal SHALL serve a public profile page at `/users/:username` that lists the user's public routes and activities in reverse chronological order, viewable without authentication.
|
||||
|
||||
#### Scenario: Logged-out visitor views a profile with public content
|
||||
- **WHEN** an unauthenticated visitor navigates to `/users/:username` for a user with at least one `public` route or activity
|
||||
- **THEN** the page renders that user's display name (falling back to username), the `@username@domain` handle, and a reverse-chronological list of their `public` routes and `public` activities
|
||||
- **AND** items marked `unlisted` or `private` do NOT appear in the list
|
||||
|
||||
#### Scenario: Profile 404 when there is no public content
|
||||
- **WHEN** a visitor navigates to `/users/:username` for a user whose content is all `private` or `unlisted`, or for a username that does not exist
|
||||
- **THEN** the server responds with HTTP 404
|
||||
- **AND** the response does NOT distinguish the two cases, so existence of a private-only account is not leaked
|
||||
|
||||
#### Scenario: Owner sees their own profile
|
||||
- **WHEN** a user navigates to their own `/users/:username` while logged in
|
||||
- **THEN** the page renders exactly the same as for a logged-out visitor, plus a small owner-only control strip linking to settings
|
||||
|
||||
#### Scenario: Profile page emits social-share metadata
|
||||
- **WHEN** any visitor loads a populated `/users/:username`
|
||||
- **THEN** the page emits Open Graph tags (`og:title`, `og:site_name`, `og:type="profile"`) so links shared on social platforms render a meaningful preview
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: View route
|
||||
The Journal SHALL display route details including map, metadata, and elevation stats. Access depends on the route's `visibility`: `public` routes are viewable by anyone including unauthenticated visitors, `unlisted` routes are viewable by anyone who has the URL, and `private` routes are viewable only by the owner.
|
||||
|
||||
#### Scenario: Owner views own route
|
||||
- **WHEN** a logged-in user navigates to a route they own at any visibility
|
||||
- **THEN** they see the route name, description, a map with the route polyline, distance, and elevation gain/loss
|
||||
|
||||
#### Scenario: Anyone views a public route
|
||||
- **WHEN** any visitor (including unauthenticated) navigates to a `public` route's URL
|
||||
- **THEN** they see the full route detail page as above
|
||||
|
||||
#### Scenario: Anyone with the URL views an unlisted route
|
||||
- **WHEN** any visitor navigates directly to an `unlisted` route's URL
|
||||
- **THEN** they see the full route detail page as above
|
||||
|
||||
#### Scenario: Non-owner is blocked from a private route
|
||||
- **WHEN** a visitor who is not the owner requests a `private` route URL
|
||||
- **THEN** the server responds with HTTP 404 (not 403), so the existence of the private route is not leaked
|
||||
|
||||
#### Scenario: Public and unlisted route pages emit social-share metadata
|
||||
- **WHEN** a visitor loads a `public` or `unlisted` route detail page
|
||||
- **THEN** the response emits Open Graph and Twitter Card meta tags (`og:title`, `og:description`, `og:type="article"`, `og:site_name`, `twitter:card="summary"`)
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Route visibility
|
||||
The Journal SHALL persist a `visibility` value on every route and SHALL allow the owner to change it.
|
||||
|
||||
#### Scenario: New routes default to private
|
||||
- **WHEN** a route is created without an explicit visibility
|
||||
- **THEN** the route row is persisted with `visibility = 'private'`
|
||||
|
||||
#### Scenario: Owner changes a route's visibility
|
||||
- **WHEN** a route owner selects a different visibility (`private`, `unlisted`, `public`) in the edit flow and saves
|
||||
- **THEN** the stored visibility is updated and subsequent access checks use the new value immediately
|
||||
|
||||
#### Scenario: Non-owner cannot change visibility
|
||||
- **WHEN** a request to update visibility arrives from a user who is not the route owner
|
||||
- **THEN** the server rejects it with HTTP 403 or 404 (matching the current update-route behaviour), and the stored value is unchanged
|
||||
|
||||
### Requirement: Route listings respect visibility
|
||||
Any listing that exposes routes beyond the owner's own dashboard SHALL only include routes with `visibility = 'public'`.
|
||||
|
||||
#### Scenario: Public profile lists only public routes
|
||||
- **WHEN** a visitor loads `/users/:username`
|
||||
- **THEN** the rendered list of routes includes only the user's `public` routes; `unlisted` and `private` routes are omitted
|
||||
|
||||
#### Scenario: Owner's own routes list is unchanged
|
||||
- **WHEN** a logged-in user views their own routes list at `/routes`
|
||||
- **THEN** the list includes all of their own routes regardless of visibility
|
||||
62
openspec/changes/public-content-visibility/tasks.md
Normal file
62
openspec/changes/public-content-visibility/tasks.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
## 1. Schema
|
||||
|
||||
- [ ] 1.1 Add `visibility text NOT NULL DEFAULT 'private'` to `journal.routes` in `packages/db/src/schema/journal.ts`
|
||||
- [ ] 1.2 Add the same column to `journal.activities` in the same file
|
||||
- [ ] 1.3 Export a shared type `type Visibility = 'private' | 'unlisted' | 'public'` from `packages/db/src/schema/journal.ts` (or a small adjacent module) and reuse at the app layer
|
||||
|
||||
## 2. Server-side access helper
|
||||
|
||||
- [ ] 2.1 Add `canView(content, user)` in `apps/journal/app/lib/auth.server.ts` that returns `true` for `public`, `true` for `unlisted` (the direct-URL case — callers pass `true` when routed to a detail page, `false` when generating listings), and ownership-checked otherwise
|
||||
- [ ] 2.2 Unit test the three matrix cells
|
||||
|
||||
## 3. Route detail access
|
||||
|
||||
- [ ] 3.1 Update `apps/journal/app/routes/routes.$id.tsx` loader: fetch route, then apply `canView`; return 404 when not allowed
|
||||
- [ ] 3.2 Expose `visibility` on the loader's returned shape so the component can render the current-visibility badge to the owner
|
||||
- [ ] 3.3 Emit Open Graph / Twitter Card `meta` for `public` and `unlisted` routes (title, description, `og:type="article"`, `og:site_name="trails.cool"`, `twitter:card="summary"`)
|
||||
|
||||
## 4. Activity detail access
|
||||
|
||||
- [ ] 4.1 Mirror 3.1 in `apps/journal/app/routes/activities.$id.tsx`
|
||||
- [ ] 4.2 Mirror 3.3 for activities
|
||||
|
||||
## 5. Visibility selector in the edit flow
|
||||
|
||||
- [ ] 5.1 Add a visibility `<select>` to `apps/journal/app/routes/routes.$id.edit.tsx`
|
||||
- [ ] 5.2 Wire the form action in `routes.$id.tsx` / the edit route to persist the new value via `updateRoute`
|
||||
- [ ] 5.3 Add i18n keys (EN + DE) for `routes.visibility.{label,private,unlisted,public,privateHelp,unlistedHelp,publicHelp}`
|
||||
- [ ] 5.4 Do the same for activities (no separate edit page exists yet — either add a minimal one or surface the selector on the detail page for the owner; pick during implementation)
|
||||
|
||||
## 6. Listing filters
|
||||
|
||||
- [ ] 6.1 Update `listRoutes(userId)` in `apps/journal/app/lib/routes.server.ts` to accept a `viewerUserId: string | null` and filter to rows where `visibility='public'` OR `owner_id = viewerUserId`
|
||||
- [ ] 6.2 Same for `listActivities` in the activities equivalent
|
||||
- [ ] 6.3 Audit any other query that returns routes/activities across users and apply the same filter (grep `users.id` / `owner_id` joins)
|
||||
|
||||
## 7. Public profile page
|
||||
|
||||
- [ ] 7.1 Broaden `apps/journal/app/routes/users.$username.tsx` loader to not require auth
|
||||
- [ ] 7.2 Fetch the user row by username; return 404 if no such user
|
||||
- [ ] 7.3 Fetch that user's `public` routes and `public` activities via the updated listing filters
|
||||
- [ ] 7.4 Return 404 if the user exists but has no public content at all (prevents account enumeration)
|
||||
- [ ] 7.5 Render the profile header (display name, `@username@domain` handle), reverse-chronological lists, and an owner-only "This is your profile" control strip linking to settings
|
||||
- [ ] 7.6 Emit Open Graph meta (`og:title`, `og:site_name`, `og:type="profile"`)
|
||||
|
||||
## 8. Copy + docs
|
||||
|
||||
- [ ] 8.1 Add a short sentence to the Privacy Manifest (`legal.privacy.tsx`) noting public content is world-visible and exportable the same way
|
||||
- [ ] 8.2 Bump `PRIVACY_LAST_UPDATED` in `apps/journal/app/lib/legal.ts` and add a new snapshot under `docs/legal-archive/`
|
||||
|
||||
## 9. Testing
|
||||
|
||||
- [ ] 9.1 Unit: `canView` matrix (private/unlisted/public × owner/other/anon)
|
||||
- [ ] 9.2 E2E: logged-out visitor can view a public route page; gets 404 on a private one
|
||||
- [ ] 9.3 E2E: owner marks a route public, logs out, can still view it at the same URL
|
||||
- [ ] 9.4 E2E: `/users/:username` 404s for a user with no public content, renders for one with at least one public item
|
||||
- [ ] 9.5 E2E: OG tags are present on public detail pages (assert via `page.locator('meta[property="og:title"]')`)
|
||||
|
||||
## 10. Rollout
|
||||
|
||||
- [ ] 10.1 Merge schema + code; `drizzle-kit push --force` adds the column with `'private'` default — no row changes
|
||||
- [ ] 10.2 Confirm on prod that the three existing users' routes/activities are still auth-only (they should be, since all rows default to `private`)
|
||||
- [ ] 10.3 Hand off to `demo-activity-bot` which inserts with `visibility: 'public'` directly
|
||||
Loading…
Add table
Add a link
Reference in a new issue