## Symptom
Grafana's \`app-crash-log\` alert fires on every deploy. The Loki query
that backs it (\`ERR_|FATAL|uncaughtException|…\`) matches:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module
'/app/apps/journal/app/lib/logger.server' imported from
'/app/apps/journal/app/lib/email.server.ts'
A real bug, not a false positive: \`email.server.ts\` line 2 was
\`import { logger } from \"./logger.server\"\` — no extension.
## Why typecheck didn't catch it
\`tsconfig.base.json\` sets \`moduleResolution: \"bundler\"\`. The bundler
resolver accepts extensionless relative imports because Vite / esbuild
/ webpack resolve them at build time. TypeScript was happy.
Production runs \`node --experimental-strip-types server.ts\`, which
uses Node's NodeNext ESM resolver — strict about extensions. The two
resolvers disagree silently for files Node loads directly (server.ts,
\`.server.ts\` modules dynamically imported from it, and jobs).
## Fix
1. **Added \`.ts\` extensions to the 4 broken imports** I could find:
- \`apps/journal/app/lib/email.server.ts\` → \`./logger.server.ts\`
(the actual deploy-blocker)
- \`apps/planner/app/lib/brouter.ts\` → \`./route-merge.ts\`, \`./http.server.ts\`
- \`apps/planner/app/lib/use-nearby-pois.ts\` → \`./overpass.ts\`
- \`packages/map/src/MapView.tsx\` → \`./layers.ts\` (caught by the rule)
2. **Added \`eslint-plugin-import-x\` with \`import-x/extensions: always\`**,
scoped to files Node actually executes raw:
- \`apps/*/server.ts\`
- \`apps/*/app/lib/**/*.server.ts\`
- \`apps/*/app/jobs/**/*.ts\`
- \`packages/*/src/**/*.{ts,tsx}\`
Ignored: route-scoped \`*.server.ts\` files (bundled by Vite into the
React Router build, never loaded by Node), and test files (Vitest's
own resolver).
## What's still possible
- Non-\`.server.ts\` files in \`app/lib\` (e.g. \`legal.ts\`, \`actor-iri.ts\`)
could still ship extensionless imports. They're not in the lint
scope. If one breaks at runtime we can extend the glob — for now
they tend to be tiny utility modules that don't import other
relatives.
- The deeper fix would be \`moduleResolution: nodenext\` for server-side
tsconfig, or bundling the server code so Node never sees raw \`.ts\`.
Bigger surgery; the lint rule covers the failure mode for now.
Full repo: pnpm typecheck / lint / test all green after \`pnpm install\`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After this, \`grep -rn 'eslint-disable' apps/ packages/\` returns 0
(excluding tests and node_modules).
**\`apps/planner/app/lib/brouter.ts\`** — \`while (true)\` in
\`readBodyWithCap\` tripped \`no-constant-condition\`. Replaced with
\`for (;;)\` which the rule explicitly allows. No behavior change.
**\`packages/gpx/src/parse.ts\`** — the sync \`parseGpx\` exported a
fallback path that did \`require(\"linkedom\")\` for non-browser sync
use, with an \`eslint-disable\` for \`no-require-imports\`. It was dead
code: \`packages/gpx/src/index.ts\` only re-exports \`parseGpxAsync\`,
and grepping the monorepo finds no caller of the sync version. Deleted
the function entirely; \`getDOMParser\` (the async helper) still serves
the async path with a clean ESM \`await import(\"linkedom\")\`.
Full repo: pnpm typecheck / lint / test all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Followup to #447. The audit ran on \`as unknown as\` first; this PR
closes out \`as any\` separately. After this, \`grep -rn ' as any\\b'
apps/ packages/\` returns 0 (excluding tests and node_modules).
## Sites fixed
**\`apps/journal/server.ts\`** + **\`packages/jobs/src/types.ts\`** —
\`komootBulkImportJob as any\`. The job had a typed payload
(\`JobDefinition<KomootBulkImportData>\`) but the worker's
\`JobDefinition[]\` array forced a contravariance cast at every site
that mixed typed and untyped jobs. Dropped the generic from
\`JobDefinition\` entirely; handlers narrow their own \`job.data\`. Only
one job (komoot-bulk-import) used the generic, so the surface is tiny.
**\`apps/journal/app/lib/connected-services/fit.ts\`** + same pattern in
\`routes/sync.import.\$provider.server.ts\` — \`parser.parse(buffer as any,
(err: unknown, d: any) => ...)\`. fit-file-parser's TypeScript types
require \`Buffer<ArrayBuffer>\` specifically; a generic Node \`Buffer\`
is structurally \`Buffer<ArrayBufferLike>\` (which includes
SharedArrayBuffer). The runtime accepts either, so narrowed the cast
to \`as Buffer<ArrayBuffer>\` — still a cast, but precise about what
we're asserting and why. Removed the \`(err, d: any) => …\` ad-hoc
callback typings; the library exports a proper \`FitParserCallback\`.
**\`apps/mobile/lib/editor/RouteMap.tsx\`** — \`onLongPress\` handler
took \`(event: any)\`. maplibre-react-native v11 generates the event
type via React Native codegen but doesn't re-export it as a public
TypeScript type. Replaced with a local structural slice of the parts
we actually read.
Net: 4 \`as any\` sites in prod code → 0. \`pnpm typecheck\` / \`lint\` /
\`test\` all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After Phase A (#442) moved the journal callback token off the browser,
the token was still replayable on the wire until \`exp\` (7 days). This
PR makes each token strictly single-use.
Changes:
- **\`journal.consumed_jwt_jti\` table** — \`jti TEXT PRIMARY KEY,
consumed_at TIMESTAMPTZ, expires_at TIMESTAMPTZ\`. Picked up by
drizzle-kit push on deploy.
- **\`createRouteToken\` now sets a \`jti\` claim** (\`randomUUID()\`).
- **\`verifyRouteToken\` atomically consumes the jti** via
\`INSERT … ON CONFLICT DO NOTHING RETURNING jti\`. Postgres
serializes the insert, so exactly one concurrent caller wins; the
rest see an empty result and throw \`TokenAlreadyConsumedError\`.
Tokens without a \`jti\` claim (i.e. minted before this PR) are
also rejected — the right call: any in-flight legacy token sitting
in a planner session is replayable, and we'd rather fail-loud than
silently grandfather them in.
- **\`consumed-jti-sweep\` job** — daily 03:45 UTC cron that
\`DELETE WHERE expires_at < now()\`. Keeps the table tiny; offset
from the other purge jobs to spread load.
- **e2e replay test** — \`integration.test.ts\` now exercises a
same-token double-submit and asserts the second returns 401 with
\`/consumed|already/i\`.
UX implication worth flagging: a user who clicks \"Save\" twice (or whose
network retries a failed POST) sees an error on the second attempt.
They go back to the journal for a fresh \"Edit in Planner\" link.
Full repo: pnpm typecheck / lint / test all green (177 + 31 integration).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The hourly `expireSessions()` cron runs `DELETE FROM planner.sessions
WHERE last_activity < cutoff`. Without an index on `last_activity`,
that's a full table scan growing linearly with the total sessions
ever created (planner sessions are never user-deleted; expiry is the
only churn path).
`drizzle-kit push` picks this up on next deploy.
Playwright runs the server via `react-router serve` with
NODE_ENV=production but against a local dev Postgres and local cookie
secrets. The guards added in 5a7bb76 refused to start under that
configuration. `E2E=true` (already set by the CI E2E job) is now the
explicit opt-out: in real production this env var is never set, so the
guard still bites.
Adds `requireSecret(name, devFallback)` in lib/config.server.ts and
`getDatabaseUrl()` in @trails-cool/db. Both:
- return the env var when set,
- fall back to the dev default in non-production,
- throw at boot in production if the env var is missing OR matches the
known dev fallback (which would otherwise silently ship a public
secret / point at localhost).
Applied to:
- JWT_SECRET (lib/jwt.server.ts) — was `?? "dev-jwt-secret-change-in-production"`
- SESSION_SECRET (lib/auth/session.server.ts) — was `?? "dev-secret-change-in-production"`
- DATABASE_URL (server.ts health + boss; packages/db migrate-data) — was
`?? "postgres://trails:trails@localhost:5432/trails"`
Why: these strings are in the repo and known to attackers. A
misconfigured prod deploy that forgot to set them would either run with
guessable signing keys (full session/JWT forgery) or connect to a
non-existent localhost DB. Better to refuse to start than to silently
operate insecurely.
Tests:
- packages/db/src/get-database-url.test.ts (5 cases)
- lib/config.server.test.ts gains `requireSecret` cases (4 new)
Full repo: pnpm typecheck, pnpm lint, pnpm test all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses 8 issues from the Journal architecture audit:
1. DB indexes on routes.ownerId + activities.ownerId. Listing queries on
these tables were full table scans; adds composite indexes matching
the order-by columns (updatedAt/startedAt/createdAt).
2. Zod validation on /api/auth/register body. Previously the action
destructured request.json() with zero schema validation.
3. N+1 GeoJSON batch fetch collapsed to a single ANY($1::text[]) query
in both routes.server and activities.server.
4. Webhook envelope validation in /api/sync/webhook/:provider.
5. AbortSignal.timeout(30s) on all external fetches (Komoot, Wahoo) via
a new fetchWithTimeout helper in lib/http.server.ts.
6. .limit(100) on listPublicRoutesForOwner / listPublicActivitiesForOwner.
9. Welcome email moved off fire-and-forget onto a pg-boss job with
retryLimit: 3 (send-welcome-email).
10. process.env.ORIGIN ?? "http://localhost:3000" centralized into
lib/config.server.ts::getOrigin() across 14 call sites.
Issues 7 (centralized apiError/auth guards across 60+ route files) and
8 (split .server.ts boundaries across 20+ route files) intentionally
deferred — both are pure refactors that would balloon this PR past
reviewability and warrant their own focused PRs.
Tests added:
- lib/config.server.test.ts (2 cases)
- lib/http.server.test.ts (3 cases — timeout abort, success passthrough,
caller-signal composition)
- routes/api.sync.webhook.$provider.test.ts (6 cases)
- routes/api.auth.register.test.ts (7 cases — schema rejection paths +
the new welcome-email enqueue assertion)
Full repo: pnpm typecheck, pnpm lint, pnpm test all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the per-tour import UI with a fire-and-forget background job:
- Add `import_batches` DB table tracking status, found/imported/duplicate counts
- Add `runKomootBulkImport` server function that pages all Komoot tours,
fetches GPX, creates activities, and deduplicates via sync_imports
- Add `komoot-bulk-import` pg-boss job registered at server startup
- Add POST /api/sync/komoot/import to enqueue the job
- Add GET /api/sync/komoot/import-status to return the latest batch
- Replace the Komoot import page with a progress UI that polls every 2s
while a batch is running and shows found/imported/skipped counts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Default sort is by activity date (startedAt); users can switch to
"Date added" (createdAt) via a URL query param (?sort=addedAt).
Activities without a startedAt fall to the bottom when sorted by date.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two-mode import: public mode verifies Komoot account ownership by checking
that the user's trails.cool profile URL appears in their Komoot bio — no
credentials stored. Authenticated mode uses email + password (AES-256-GCM
encrypted) to import private tours as well.
Includes unit tests for crypto/komoot client and E2E tests for the full
connect + import flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- `note` field on Waypoint type, stored in Yjs Y.Map and exported as
`<desc>` inside `<wpt>` in GPX
- Inline textarea note editing in WaypointSidebar with auto-resize,
character counter (500 max), save-on-blur, Escape cancel
- Note indicator dot on map markers; note tooltip on hover
- `useNearbyPois` hook: fetches POIs within 500m of selected waypoint
via Overpass proxy, 500ms debounce, AbortController, 60s rate-limit
suppression
- NearbyPoiMarkers component: renders POI markers on map for selected
waypoint with snap-to-POI on click
- Nearby section in WaypointSidebar: list with snap buttons, spinner,
empty/rate-limited states, "Show more" toggle (5 → all)
- Unit tests for fetchNearbyPois bbox geometry and snap-to-POI Yjs
transaction behaviour
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Waypoints snapped to OSM POIs in the Planner now carry their metadata all
the way through to the Journal:
- Extend Waypoint type with osmId and poiTags fields
- Extract osmId/poiTags from Yjs Y.Map in ExportButton and SaveToJournalButton
- Encode POI metadata as <trails:poi> extensions in GPX <wpt> elements
- Parse <trails:poi> extensions back in the GPX parser
- Display phone, website, opening hours, address on Journal route detail
- E2E test for the full roundtrip; seed endpoint now defaults to public visibility
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mobile API requests authenticated via OAuth2 bearer tokens bypassed the
Terms gate that the root loader applies to web cookie sessions. Extend
requireApiUser to compare the user's termsVersion with TERMS_VERSION
and return a structured 403 { code: "TERMS_OUTDATED", currentTermsVersion }
on mismatch so mobile clients can surface their own re-acceptance UI.
Spec delta on journal-auth captures the new requirement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements tasks 1.1-1.4 and 2.1-2.5 of deepen-connected-services:
DB:
- Rename journal.sync_connections -> journal.connected_services.
- Add credential_kind discriminator (oauth | web-login | device) and
credentials JSONB (shape per kind), status column, and a unique index
on (user_id, provider) lifting the previously app-only invariant
into the DB.
- Idempotent backfill in 0002_connected_services.sql moves existing
Wahoo rows' tokens into the JSONB blob with credential_kind='oauth'.
Code:
- New apps/journal/app/lib/connected-services/ module:
- types.ts: ConnectedService, CredentialKind, OAuthCredentials,
NeedsRelinkError, CredentialAdapter, ProviderOAuthConfig, etc.
- credential-adapters/oauth.ts: standard OAuth2 refresh_token flow,
revoke endpoint, 4xx -> NeedsRelinkError / 5xx -> transient.
- manager.ts: ConnectedServiceManager (link, unlink, withFreshCredentials,
markNeedsRelink). Centralizes credential lifecycle in one chokepoint.
- registry.ts: ProviderManifest type + capability seam interfaces
(Importer, RoutePusher, WebhookReceiver). Manifests register
themselves at import time.
Tests:
- manager.test.ts (8 tests): refresh-on-expired, refresh-fail->needs_relink,
ConnectionNotActiveError, link/unlink, revoke is best-effort.
- credential-adapters/oauth.test.ts (10 tests): refresh contract,
refresh_token retention, 4xx vs 5xx behaviour, revoke.
- All 18 new tests pass.
Compatibility:
- apps/journal/app/lib/sync/connections.server.ts is now a thin shim
translating the legacy TokenSet API onto the JSONB-shaped table so
existing callers (routes, pushes.server.ts) keep working until tasks
5.x migrate them to the manager. To be deleted in task 5.6.
Pre-existing journal test failures (12) are unrelated to this change:
they pre-date this PR and stem from a workspace resolution issue with
@trails-cool/fit (verified by running tests against main).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-pushing an edited route to Wahoo now updates the existing remote
route via PUT against the stored remote_id instead of POSTing a new
copy. external_id drops the version suffix and identifies the logical
route. sync_pushes is keyed by (user, route, provider) and tracks
last_pushed_version for the "local newer" UI state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The Course message advertised capabilities=0x04 (time only), telling
consumers the course had no position data. Wahoo accepted the route
(distance/elevation come from request fields) but rendered an empty
map. Set capabilities to valid|distance|position (0x1A).
Also write a real lap totalElapsedTime/totalTimerTime derived from the
1Hz record timestamps; a zero-duration lap can be rejected as malformed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Disconnecting a sync provider now calls the provider's revoke endpoint
(DELETE /v1/permissions for Wahoo) before dropping the local row, so
tokens don't accumulate against Wahoo's per-(app,user) cap. The token
exchange now classifies Wahoo's "Too many unrevoked access tokens" 400
as a distinct OAuthError code, and the connections settings page shows
a localized banner for that and other connect failures instead of a
silent ?error=sync_failed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Route detail page now renders one of three states for the owner of a
route with a Wahoo connection:
- "Send to Wahoo" button + privacy tooltip when no successful push
exists for the current version, plus the inline last-error blurb
if the previous attempt failed
- "Sent to Wahoo on <date>" pill when sync_pushes has a pushedAt
for the current version
- Banner row at the top mapping the ?push= and ?code= query params
the action route appends to user-facing copy
i18n strings cover all 8 banner states (success, needs_permission,
no_connection, no_geometry, validation, rate_limit, token_expired,
generic) in English and German.
Privacy disclosure lives at /legal/privacy (apps/journal/app/routes/
legal.privacy.tsx), the in-app surface this repo uses instead of a
docs/privacy.md manifest. The new bullet declares that route
geometry, name, and description are transmitted to Wahoo on opt-in
via the Send to Wahoo button.
E2E tests for the push and re-auth flows (tasks 9.4/9.5) are
deferred — they need a server-side Wahoo mock harness that doesn't
exist yet. Slice-4 unit tests cover the pipeline against the
provider mock.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds /api/sync/push/:provider/:routeId as the user-triggered entry
point for pushing a route. The route action delegates to a new
pushes.server.ts pipeline that:
- resolves the latest route_versions row and uses that GPX (not
routes.gpx) so the bytes Wahoo gets match the snapshot the user
sees
- short-circuits on (user, route, version, provider) idempotency:
a successful prior push returns the existing remote_id without
re-calling Wahoo
- detects scope_missing before hitting Wahoo and redirects through
getAuthUrl with a base64url-encoded state carrying pushAfter +
returnTo
- refreshes tokens once on PushError({ code: "token_expired" }) and
retries the push, then updates sync_connections in place
- records every outcome in sync_pushes (insert on first attempt,
update on retry) so the UI can show success/failure state
The OAuth callback handler now decodes the state, resumes a
pushAfter pipeline server-side after exchangeCode, and handles the
?error=access_denied path with a needs_permission notice.
Also flips packages/fit/src/fitsdk.d.ts to a regular .ts side-effect
shim so journal's tsc picks up the @garmin/fitsdk module declaration
when consuming the workspace package via source.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
sync_pushes tracks outbound route pushes to providers, keyed by
(user_id, route_id, route_version, provider). Successful rows hold
the remote_id; failed rows hold the error and can be retried in
place. The unique index makes idempotent push trivial.
granted_scopes records the OAuth scope set we requested at
exchangeCode time. Wahoo doesn't return a scope field in token
responses and grants scopes all-or-nothing, so the requested set is
the source of truth. Defaults to an empty array, which means any
pre-existing connection will be flagged as scope-mismatched on the
first routes_write push — the intended UX for the slice 3 re-auth
flow.
Adjusts tasks 2.3/2.4 in the spec to match repo reality: this
project runs `drizzle-kit push --force` schema-first, with no
checked-in migrations directory.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wraps @garmin/fitsdk to emit FIT Course files from GPX, the binary
format Wahoo's POST /v1/routes API requires. Server-side only — the
~1 MB SDK never ships to the planner browser bundle.
Round-trip tests use fit-file-parser as an independent oracle and
assert lat/lon parity within 1e-4 deg and altitude within 0.5 m
across short flat, alpine, multi-day, and single-point fixtures.
Updates design.md decision #1: the original hand-rolled-encoder plan
was justified largely by ESM friction in the Garmin SDK, but as of
v21.202.0 the SDK is pure ESM with zero deps. Wrapping it saves us
~400 LOC of binary plumbing and ongoing maintenance against future
FIT spec updates.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Stream C from docs/information-architecture.md. The navbar's account
cluster (<username> + Settings + Logout) was three controls for the
same concept; the cluster now collapses behind an avatar dropdown.
On mobile, all primary nav and account controls move into a
hamburger drawer so the top bar stays at logo + bell + hamburger
without wrapping at small viewports.
- New apps/journal/app/components/Avatar.tsx — initials-only avatar
(no image-upload story yet; the component is the single place to
add image fallback when one lands).
- New apps/journal/app/components/AccountDropdown.tsx — click the
avatar trigger to reveal Profile / Settings / Log Out menuitems.
Click-outside + Escape-to-close. role="menu" + role="menuitem" for
a11y.
- New apps/journal/app/components/MobileNavMenu.tsx — hamburger
trigger + slide-out drawer at md and below. Contains all primary
nav (Feed, Explore, Routes, Activities, Notifications) plus the
account cluster (Profile, Settings, Log Out). Auto-closes on
navigation; locks body scroll while open.
- apps/journal/app/root.tsx — refactor NavBar:
* Brand + primary nav links hide at md and below (drawer covers
them).
* Right cluster: bell on every viewport; avatar dropdown on
desktop, hamburger on mobile.
* Loader exposes user.displayName so Avatar can compute initials.
* Drops the now-unused Form import; Form moved into the
dropdown/drawer components.
E2E tests updated:
- e2e/auth.test.ts — logout helper opens the avatar dropdown via
aria-label = displayName||username, then clicks the Log Out
menuitem. Username assertions in nav switch from getByText to
getByRole("button", {name: username}) (the avatar button).
- e2e/settings.test.ts — "settings link visible in nav" opens the
avatar dropdown first, then asserts the Settings menuitem.
i18n: nav.openMenu, nav.closeMenu keys for the hamburger trigger
labels (EN + DE).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stream E from docs/information-architecture.md. The settings page was
a single scrollable list of five concerns; split it into four
deep-linkable sections behind a shared sidebar layout, so each
concern has a stable URL, a focused loader (only fetches what that
section needs), and a meta title that reflects the section.
Sections:
- /settings/profile — display name, bio, profile visibility
- /settings/account — email change + danger-zone account deletion
- /settings/security — passkeys
- /settings/connections — sync providers (Wahoo today)
URL pattern: nested routes under a layout. /settings itself
redirects to /settings/profile so the bare URL still lands somewhere
useful without rendering an empty container.
- apps/journal/app/routes/settings.tsx — converted from a single
scrollable page to a layout that renders the sidebar nav + Outlet.
- apps/journal/app/routes/settings.{profile,account,security,
connections,_index}.tsx — new files, each with its own loader and
meta. _index redirects to /settings/profile.
- apps/journal/app/routes.ts — registers the four children + index
under the settings layout route.
- packages/i18n/src/locales/{en,de}.ts — new settings.nav.{profile,
account,security,connections} keys for the sidebar labels.
Specs (profile-settings, account-management, connected-services)
are unchanged — they describe behavior, not URL structure. The
journal-landing spec is also unchanged; the navbar still has a
single "Settings" link.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stream F implementation. Closes the gap between "I want to follow
someone on this instance" and "I have a username from outside the
app." Anonymous visitors and signed-in users both reach a paginated
directory of local public users; signed-in viewers also see Follow
buttons inline.
- apps/journal/app/lib/explore.server.ts — listDirectory (paginated,
ordered by MAX(public-activity created_at) DESC NULLS LAST,
tiebreaker users.id DESC), listActiveRecently (top 5 in last 30
days), countDirectory, batched countFollowersBatch and
getFollowStateBatch helpers so the page issues two extra queries
regardless of page size. Excludes private profiles and the demo
persona; banned/suspended scaffolding noted for forward-compat.
- apps/journal/app/routes/explore.tsx — loader fans out the four
queries in parallel; component renders an "Active recently" strip
(hidden when empty) above the main directory; FollowButton inlines
per row for signed-in viewers. Bio truncated to 120 chars.
- apps/journal/app/routes.ts — register /explore.
- apps/journal/app/root.tsx — add Explore navbar entry for signed-in
users.
- apps/journal/app/routes/home.tsx — visitor home gains a secondary
"Browse who's here →" link to /explore alongside the Planner
escape hatch.
- packages/i18n/src/locales/{en,de}.ts — explore.*, nav.explore,
home.exploreLink keys.
- apps/journal/app/lib/explore.integration.test.ts — opt-in
(EXPLORE_INTEGRATION=1) integration tests covering inclusion,
exclusion, ordering, NULLS LAST behavior, "Active recently"
30-day filter, count helpers.
- e2e/explore.test.ts — anonymous loads /explore (no auth needed);
private profile is excluded from the directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stream A from docs/information-architecture.md: signed-in users now
have a single /feed destination with two views — Followed (default,
people they accepted-follow) and Public (instance-wide). Logging in no
longer hides the public instance feed; switching is a query-param flip
that's bookmarkable and SSR-rendered.
- apps/journal/app/routes/feed.tsx — loader reads ?view=, branches
fetch (listSocialFeed vs listRecentPublicActivities, both already
exist), passes view to the component. Component renders a tab strip
at the top using plain <Link>s so the toggle works without JS. Per-
view <meta> title and empty state. The "see public feed" escape
from the empty Followed view now points at ?view=public instead of
/, keeping the user on /feed.
- packages/i18n/src/locales/{en,de}.ts — new social.feed.toggle.{ },
social.feed.public.{heading,empty}, and social.feed.seePublic
keys; old social.feed.publicFeedLink renamed to seePublic.
- openspec/specs/social-follows/spec.md — Social activity feed
requirement extended with the two-view structure, including the
Public view, the toggle, and the unrecognized-value fallback.
- openspec/specs/activity-feed/spec.md — Instance-wide public
activity feed requirement notes the Public view of /feed is now a
consumer alongside the visitor home.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Folds the actionable follow-requests surface into the Notifications page
as a Requests tab (alongside the existing Activity tab), so the navbar
exposes a single bell instead of two adjacent inboxes. The Requests tab
shows a count badge for pending rows regardless of read state, while the
bell badge keeps reflecting the unread-notifications count (which already
covers `follow_request_received` rows). The standalone /follows/requests
URL is preserved as a 301 redirect so prior notification deep-links,
emails, and bookmarks still resolve.
Driven by the IA review captured in docs/information-architecture.md.
Specs (notifications, social-follows, journal-landing) are updated in
the same change to reflect the new structure.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches `listForUser` from page-offset to cursor (`before` param,
opaque base64 of `{ts, id}`) ordered by `(created_at DESC, id DESC)`
for stable pagination even with simultaneous fan-out inserts.
Returns `{ rows, nextCursor }` instead of a bare array. Loader surfaces
`?before=<cursor>` on a "Load older" link at the bottom of the list,
shown only while `nextCursor !== null`. Default page size 50, capped
at 100. Malformed cursors fall back to "start from top" rather than
400ing — opaque cursors should not be a client validation surface.
Spec drift: delta spec adds three pagination scenarios (cursor pages
forward, tie-stable on identical `created_at`, malformed-cursor
graceful fallback). Design doc gets a new decision section explaining
the cursor choice over page-offset and why we don't compute totals.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the notifications system end-to-end (4 types, payload-versioned
JSONB, SSE-based live unread badge, /notifications page, mark-read API,
fan-out job for activity_published, daily 90-day retention purge).
Bell icon in the navbar with unread badge.
Side-findings from exercising the change:
- Add 6-digit magic code to registration (mirrors login UX, mobile
paste-friendly), with `[Register Magic Link]` console line in dev so
the code is reachable without a real email transport.
- Manual passkey/magic-link toggle on the register form (login already
had it).
- Restrict ALPN to http/1.1 in HTTPS dev so React Router's
singleFetchAction CSRF check (Origin vs. Host) passes — Node doesn't
synthesize Host from h2's :authority. Plain HTTP dev unaffected.
- Followers/Following routes now use the locked-account rule from the
profile route (owner + accepted followers see the list; others 404).
Profile page renders the count chips as plain spans for viewers who
can't see the lists, so private profiles don't surface dead links.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the earlier 404-for-private model with Mastodon-style locked
accounts. A private profile now returns 200 with a stub layout and
gates content behind follow approval. Default for new users flips from
'public' to 'private' to align with trails.cool's privacy-first
content defaults.
Schema:
- users.profile_visibility default flipped to 'private'. Existing rows
remain 'public' (backfill on first migration handled them).
Follow API (follow.server.ts):
- followUser now creates Pending (accepted_at = NULL) against private
targets and Accepted against public targets — no more refusal.
- New: countPendingFollowRequests, listPendingFollowRequests,
approveFollowRequest, rejectFollowRequest. Approve/reject are
owner-bound: only the followed user can act on their own incoming
requests.
- countFollowers / countFollowing / listFollowers / listFollowing now
filter to accepted-only relations.
Loader (users.$username.tsx):
- Drops the 404 paths. New canSeeContent flag = isOwn ||
profile_visibility='public' || (followState.following === true).
- When canSeeContent=false, render a stub: header + 🔒 badge + body
copy + Request-to-follow / sign-in CTA. Routes/activities sections
are not rendered.
UI:
- FollowButton gains a "Request to follow" / "Requested" state for
private targets via a new isPrivateTarget prop. Cancel-request reuses
the unfollow endpoint.
- New /follows/requests page lists incoming Pending requests with
Approve / Reject buttons.
- New API routes: POST /api/follows/:id/approve and /reject.
- Navbar shows a count badge linking to /follows/requests when
pending > 0.
Privacy manifest already documents the follows relation; no changes
needed (the locked-account semantics don't add new data — same row,
different lifecycle).
Specs / design (social-feed change):
- public-profiles delta rewritten around the four-mode locked model
(public, private+anon, private+pending, private+accepted) with
scenarios for each.
- social-follows delta gains Pending lifecycle requirements (auto vs.
manual accept, approve/reject endpoints, pending request management,
Pending follows do not contribute to feed).
- design.md decision section reflects the new model and rationale for
default-private; non-goal "locked-local-accounts as a follow-up" is
removed since this change ships it.
Tests:
- follow.integration.test.ts: pending-against-private, approve flips
to accepted, reject deletes, owner-bound enforcement.
- e2e/social.test.ts: full Request → Pending → Approve → full-view
flow, plus stub-for-anonymous and /follows/requests auth gate.
Supersedes PR #309 (closed): the empty-public-profile 200 is now a
side-effect of the new render path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the social-feed change end-to-end. Local-only follows
between users on the same instance, an aggregated /feed of public
activities from people you follow, and an explicit profile_visibility
setting so the question "can someone follow me?" has a deterministic
answer.
Schema (additive, drizzle-kit push --force in cd-apps handles it):
- journal.follows table keyed by `followed_actor_iri TEXT` for
federation forward-compat. Local IRIs look like
`${ORIGIN}/users/${username}`. `accepted_at` is nullable so the
Pending state from social-federation slots in without migration.
- journal.users.profile_visibility ('public' | 'private', default
'public'). Existing users land 'public' via the default; current
effective behavior is unchanged.
Server (apps/journal/app/lib):
- actor-iri.ts: localActorIri(username) helper — single source of
truth for IRI construction.
- follow.server.ts: followUser / unfollowUser / getFollowState /
countFollowers / countFollowing / listFollowers / listFollowing.
Refuses self-follow + private targets. Idempotent.
- activities.server.ts: listSocialFeed(followerId, limit) joining
follows → activities WHERE visibility='public', reverse-chrono.
Routes:
- POST /api/users/:username/follow + /unfollow (session-bound)
- /feed (signed-in only; redirects anon to /auth/login)
- /users/:username/followers + /users/:username/following (paginated)
- /users/:username gates on profile_visibility AND has-public-content
for visitors; owners on private get an amber explainer banner.
- /settings adds a Public/Private radio with explainer text.
UI:
- FollowButton component on profile page (hidden for owner + anon).
- Follower/following counts on profile linking to collection pages.
- "Feed" link in nav (signed-in) + on personal dashboard alongside
"New Activity".
Privacy manifest updated to document the new follows relation and
profile_visibility setting.
Tests: follow.integration.test.ts (FOLLOW_INTEGRATION=1) for the
follow lifecycle; e2e/social.test.ts for /feed redirect, follow
button + count transitions, and the profile_visibility 404 toggle.
Local development: run `pnpm db:push` after pulling to apply the
schema additions. Production migrates automatically via cd-apps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously a signed-in user landing on `/` saw the same page as an
anonymous visitor: the visitor hero, the flagship marketing cards, and
the instance-wide public feed. "Home" should mean "your stuff."
The home route now branches on session: signed-out visitors keep the
marketing + public-feed layout from #303; signed-in users get a
personal dashboard showing their own activities reverse-chronologically
(all visibilities), a welcome line linking to their profile, and a
"New Activity" CTA. The public instance feed and marketing blurbs are
suppressed for signed-in users — they'd be redundant on a personal
landing surface.
Loader picks `listActivities(user.id)` vs `listRecentPublicActivities`
based on session, so only one feed query runs per request.
Updates the `journal-landing` spec with the new session-based split and
a "Personal dashboard for signed-in users" requirement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old home was an h1, subtitle, and two auth buttons — visitors
arriving at trails.cool had no idea what it was, and self-hosted
instances had nothing interesting to land on.
The new home is one layout that serves both audiences:
- Hero: a product-describing h1 ("Federated outdoor journal") +
tagline. The site name already lives in the top banner and nav brand,
so the h1 carries the pitch instead of triplicating "trails.cool".
- Auth CTAs: Register (blue) + Sign In (outlined) get primary weight.
Planner demotes to a small "Or try the Planner without an account →"
link below them — it's a nice escape hatch but not the goal.
- Marketing cards: on the flagship only, a 2x2 grid of emoji-icon cards
for Planner / Journal / Federation / Ownership, matching the Planner
home's visual weight. `IS_FLAGSHIP=true` toggles it. Self-hosters get
a "Powered by trails.cool — about the project" link back to the
flagship instead, so they don't have to write their own marketing.
- Public feed: reverse-chronological list of the 20 most recent public
activities on the instance, with owner + distance + date. Empty state
for fresh installs.
Plumbing:
- `listRecentPublicActivities(limit)` in activities.server.ts joins
users so the feed can render "by <displayName>" in one query.
- `IS_FLAGSHIP` env wired through docker-compose.yml; cd-apps and
cd-infra both write `IS_FLAGSHIP=true` alongside `DOMAIN=trails.cool`,
so self-hosters (who deploy without these workflows) default to off.
Specs:
- `activity-feed`: new "Instance-wide public activity feed" requirement
so the visibility contract is pinned (public in feed, unlisted +
private are not).
- `journal-landing`: new small spec capturing the hero / marketing /
feed layout contract and the `IS_FLAGSHIP` toggle, so future
instance-branding or empty-state changes have a stable anchor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Supersedes dependabot #264 — this one involves code changes that PR
couldn't make.
## Breaking changes in our usage surface
### v12: default export removed → named `PgBoss` export
Affects every file that imports the SDK:
- `packages/jobs/src/boss.ts`: `import PgBoss` → `import { PgBoss }`
- `packages/jobs/src/worker.ts`: same for the type import
- `packages/jobs/src/types.ts`: `PgBoss.Job<T>` → `Job<T>` (types.ts
re-exports `Job` at the package root in v12)
### v11: queue names restricted to `[A-Za-z0-9_.-]`
Colon `:` is no longer allowed. Renamed the two journal queues that
had it:
- `demo-bot:generate` → `demo-bot-generate`
- `demo-bot:prune` → `demo-bot-prune`
The planner's `expire-sessions` was already valid.
### v12: minimum Node 22.12
Not a code change for us — journal + planner Dockerfiles are on
`node:25-slim`, CI runners on 24.
## Regression guard
Added `assertValidJobName()` in `@trails-cool/jobs`, called by
`startWorker()` before any side effects. Pg-boss v11+ silently accepts
an invalid name then rejects the underlying SQL call later — we fail
loudly at boot instead. Unit tests cover the character-class rule
and exercise the exact old-name (`demo-bot:generate`) as a
regression fence.
## Prod rollout note
Pg-boss v11 dropped the auto-migration path from v10. On deploy, the
live `pgboss` schema from v10 won't migrate cleanly. Simplest path:
`DROP SCHEMA pgboss CASCADE` before the first v12 worker starts —
our jobs are all cron-scheduled and will re-register themselves on
boot, so there's nothing durable to preserve in the queue.
## Verified
- `pnpm typecheck` / `pnpm lint` / `pnpm test` — all clean
- `pnpm exec playwright test --workers=2` — 50/50 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five journal API route handlers had the same inline lambda:
parsed.error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
}))
Pull it into `zodIssuesToFieldErrors(error)` next to FieldError in
`@trails-cool/api/errors.ts` and re-export it so all five handlers
share the same implementation. The path-flattening rule now lives in
one place, and new routes get it for free.
Public error-response shape is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the deprecated v3 string-method forms with the top-level
validator helpers zod 4 prefers:
- z.string().url() → z.url() (3 sites)
- z.string().uuid() → z.uuid() (5 sites)
- z.string().datetime() → z.iso.datetime() (9 sites)
Also swap the ZodIssueCode.custom compat-shim reference in
demo-bot.server.ts's superRefine for the bare string literal "custom",
which is v4's idiomatic form. No runtime change.
All 147 tests still pass.
The `parsed.error.issues.map(i => ({field, message}))` pattern in the
five journal API route handlers stays as-is — the fields we read are
unchanged in v4, and zod's new error helpers (z.treeifyError,
z.prettifyError) produce different shapes that would change our
public API error contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical bump across the three packages that pull zod directly:
- packages/api (8 schema files, ~300 lines)
- apps/journal (persona schema in demo-bot.server.ts, 5 API route
handlers that introspect parsed.error.issues)
- apps/mobile (declared dep; no active imports yet)
No code changes required. The legacy methods we use (.url(), .uuid(),
.datetime() on strings) still parse identically in v4 via the compat
shim, and z.ZodIssueCode.custom retains its runtime value. Error-issue
introspection (parsed.error.issues.map(i => ...)) keeps the same shape
at the fields we read (path, message, code).
All 147 tests across journal + api + packages pass against zod 4.3.6.
Follow-ups out of scope for this PR:
- Modernise to top-level validator helpers (z.url(), z.iso.datetime()).
The legacy string methods are deprecated but not removed.
- Consider migrating error introspection to the richer v4 shape (.path
is now branded, .input is new) if we ever want structured error
surfaces.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a background demo user (`bruno`) plus two pg-boss jobs that generate
public trekking routes and activities in inner Berlin and prune them
after 14 days. Disabled by default everywhere; flip `DEMO_BOT_ENABLED`
only in prod.
- Schema: `synthetic` boolean on routes + activities
- `ensureDemoUser()` idempotent insert + badge on /users/bruno
- Generation gate: 07-21 Berlin local, p=0.12, 40-route cap in 14d
- Initial 4-walk backfill on first enable; retention via daily prune
- Prometheus gauges `demo_bot_synthetic_routes_total` / `_activities_total`
- Unit + DB-gated integration + E2E tests
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the public-content-visibility OpenSpec change. Adds the
smallest social surface that lets us demo the product to logged-out
visitors without user signup.
Schema:
- `visibility text NOT NULL DEFAULT 'private'` on routes + activities.
- Shared Visibility type exported from the schema module.
Access:
- New canView(content, viewer, { asDirectLink }) helper in auth.server.ts
centralises the rule: public → anyone; unlisted → anyone on direct
link; private → owner only.
- routes.$id and activities.$id loaders return 404 (not 403) when
canView rejects, so existence of private content isn't leaked.
- Detail pages emit Open Graph + Twitter Card meta on public/unlisted
content only.
Editing:
- Visibility <select> on routes/:id/edit with owner-only access.
- Activity detail page gets a small visibility form + set-visibility
action intent (no separate activity-edit page needed).
- EN + DE i18n under routes.visibility.* and activities.visibility.*.
Listings:
- Listing helpers listPublicRoutesForOwner / listPublicActivitiesForOwner
for cross-user queries. Existing owner-scoped listRoutes/listActivities
stay — owners see their own content regardless of visibility.
Public profile:
- /users/:username is now truly public. Renders the user's public
routes + activities, 404s when no public content exists AND viewer
isn't the owner (prevents account enumeration).
- Owner sees a short "this is your profile" note linking to settings.
- Open Graph meta (og:type=profile) for shareable preview.
Privacy manifest:
- Added a bullet noting public content is world-visible on profile
and indexable by search engines.
- Bumped PRIVACY_LAST_UPDATED to 2026-04-20 + rendered legal-archive
snapshot.
Tests:
- 13 unit tests for canView covering the full matrix.
- 6 E2E tests in e2e/public-content.test.ts covering:
- Private route → 404 for logged-out visitor
- Public route → reachable + OG tags present (og:title, og:type,
og:site_name)
- Owner still sees own private content
- Profile 404 when no public content
- Profile renders when at least one public route exists
- Unlisted route reachable via direct URL but hidden from profile
- Test file runs serially (describe.configure mode=serial) to avoid
WebAuthn virtual-authenticator races under Playwright's default
parallel workers.
- New public-content Playwright project added to config.
Rollout safety: every existing row in prod keeps visibility='private'
by default — nothing becomes visible to outsiders until an owner
explicitly opts in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Observed: both non-maintainer users (pistazie, nelli) created a route,
never planned in it, and stopped. The Route detail page rendered as a
dead end — name, a row of buttons, then blank.
Changes:
- Detect "empty" as no geometry AND no distance (the state every new
route has before any planning happens).
- When empty, hide the top-row button cluster (Edit in Planner / Edit
/ Export GPX) and render a centered dashed-border card in its place.
- Card shows: large headline, short body (owner-specific vs
viewer-only copy), primary "Open in Planner" button (same action as
the existing top-row button, just visually prominent), and a
secondary "or upload a GPX file" link to the edit page.
- Bilingual copy added under routes.empty.*.
No non-empty route loses any UI — those paths stay identical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The three pre-legal-disclaimer users (ullrich, pistazie, nelli) have
NULL terms_version, and any future Terms update would leave every
existing user in the same state. Close the loop now that we have
version storage by redirecting any logged-in user whose
users.terms_version doesn't match the currently-published
TERMS_VERSION to a dedicated acceptance page.
Changes:
- auth.server: new recordTermsAcceptance(userId, version) helper that
writes both terms_accepted_at and terms_version.
- root loader: if the session user has a stale or NULL terms_version,
throw redirect("/auth/accept-terms?returnTo=<pathname>") unless the
request is already on an allow-listed path
(/auth/accept-terms, /auth/logout, /legal/*) so Terms are reachable
and logout works.
- New route /auth/accept-terms (GET renders the prompt, POST records
acceptance and bounces to a sanitised returnTo). Same-origin check
on returnTo to avoid open-redirect abuse. Logout button is provided
as an escape hatch.
- i18n: new auth.reaccept.* keys for EN and DE.
- Spec: new Requirement + five scenarios (redirect, allow-list,
successful re-accept, missing consent, returnTo sanitisation).
No action on the three legacy users is required beyond what they'll
experience on their next visit — the gate takes care of it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>