Compare commits
1 commit
main
...
fix/profil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a2f0bf089 |
940 changed files with 12989 additions and 56647 deletions
|
|
@ -1,275 +0,0 @@
|
|||
---
|
||||
name: ia-review
|
||||
description: Take a snapshot of the apps' information architecture (sitemap, navigation, audience-gating per route) and write or refresh `docs/information-architecture.md`. Use when the user wants to review the IA, plan a navigation/surface redesign, or check for drift since the last review.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: trails.cool
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
Walk the apps' route table + navigation surfaces, build a sitemap, surface
|
||||
tensions and open questions, and capture the result in
|
||||
`docs/information-architecture.md` (fresh write or refresh against the
|
||||
prior snapshot).
|
||||
|
||||
The output is a *review document for the user* — not a unilateral plan.
|
||||
Decisions are made by the user during the conversation that follows; the
|
||||
doc captures the snapshot and the open questions that need answering.
|
||||
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
- The user explicitly asks for an IA review ("review the IA", "what's
|
||||
the information architecture look like").
|
||||
- Before a navigation/surface redesign, so the redesign is informed by
|
||||
a current snapshot rather than a vibe.
|
||||
- After a chunk of new features — pages, modals, settings sections —
|
||||
to check whether the IA is drifting (busy navbar, duplicated surfaces,
|
||||
orphaned routes).
|
||||
- When the user says "we should do another IA review" after the prior
|
||||
doc has aged.
|
||||
|
||||
---
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Detect mode (fresh vs refresh)**
|
||||
|
||||
Check whether `docs/information-architecture.md` already exists.
|
||||
|
||||
- **No file:** fresh review. Build the snapshot from scratch.
|
||||
- **File exists:** refresh review. Read it first; preserve the
|
||||
decisions/backlog the user has accumulated and only update the
|
||||
snapshot sections (sitemap, navigation, observations, open
|
||||
questions). Decisions previously crossed out stay crossed out.
|
||||
|
||||
In refresh mode, also read the snapshot date at the top — anything
|
||||
shipped *since* that date is what your refresh should focus on.
|
||||
|
||||
2. **Identify the apps in scope**
|
||||
|
||||
trails.cool ships two front-ends; the IA question lives mostly in
|
||||
the Journal:
|
||||
|
||||
- `apps/journal/` — user accounts, social, content. Main IA
|
||||
surface.
|
||||
- `apps/planner/` — anonymous, ephemeral. ~5 routes; include for
|
||||
completeness but don't dwell.
|
||||
|
||||
If the project structure has changed and there's a new app, include
|
||||
it.
|
||||
|
||||
3. **Read the route tables**
|
||||
|
||||
For each app:
|
||||
|
||||
```
|
||||
apps/<app>/app/routes.ts
|
||||
```
|
||||
|
||||
This is the authoritative URL → route-file mapping. Both apps use
|
||||
explicit registration (per CLAUDE.md), so `routes.ts` is complete.
|
||||
|
||||
4. **Read the navigation surfaces**
|
||||
|
||||
- `apps/journal/app/root.tsx` (and `apps/planner/app/root.tsx` if
|
||||
it has navigation) — the top navbar lives here. Read both the
|
||||
loader (to see what data the navbar consumes — counts, badges,
|
||||
user fields) and the `NavBar` component (to see what entries
|
||||
render).
|
||||
- `apps/journal/app/components/Footer.tsx` — the footer.
|
||||
- Any auth-gate / Terms-gate logic in the root loader.
|
||||
|
||||
5. **Sample key route loaders to understand audience**
|
||||
|
||||
For each top-level route, scan its loader to determine:
|
||||
|
||||
- Does it require a session? (loaders typically `redirect("/auth/login")`
|
||||
for anonymous visitors when so.)
|
||||
- Does it serve different content per session? (e.g., `home.tsx`
|
||||
branches on `user`.)
|
||||
- Does it have an access rule beyond auth? (locked-account 404s,
|
||||
visibility checks, etc.)
|
||||
|
||||
You don't need to read every route — pick the top-level ones and
|
||||
any that look like they might gate differently than the URL hints.
|
||||
|
||||
6. **Build the sitemap**
|
||||
|
||||
Group routes by audience: **Public surface** (anonymous-reachable)
|
||||
and **Authenticated surface** (signed-in only). Within each group,
|
||||
order by topic (auth, profile, content, settings, legal, etc.).
|
||||
|
||||
Use plain code blocks with one URL per line and a one-line gloss
|
||||
per entry. Keep the format scannable; don't repeat what the URL
|
||||
already says.
|
||||
|
||||
7. **Map navigation surfaces**
|
||||
|
||||
Two short tables/snippets:
|
||||
|
||||
- **Navbar (signed-in)** — entries left-to-right.
|
||||
- **Navbar (signed-out)** — entries left-to-right.
|
||||
- **Footer** — links + any meta text.
|
||||
|
||||
Note any entry whose visibility is conditional (badge counts, etc.).
|
||||
|
||||
8. **Identify "feed concepts" and other duplications**
|
||||
|
||||
trails.cool has historically had multiple feed-like surfaces. Any
|
||||
IA review should ask: how many lists of activities are there? Is
|
||||
the same data reachable from multiple URLs? Is there a URL that
|
||||
shows different products to different audiences?
|
||||
|
||||
Capture these in a small table or section if they exist.
|
||||
|
||||
9. **Map cross-app linking**
|
||||
|
||||
Journal ↔ Planner cross-links (JWT callback URLs, "Try the
|
||||
Planner" buttons, etc.). One short list.
|
||||
|
||||
10. **List observations**
|
||||
|
||||
Walk the snapshot and call out tensions worth discussing. Useful
|
||||
prompts:
|
||||
|
||||
- **Busy clusters** — three or more controls for the same concept
|
||||
side-by-side in the navbar.
|
||||
- **Redundant paths** — same destination reachable from multiple
|
||||
surfaces with no clear reason.
|
||||
- **Dead-end routes** — pages reachable only by typing the URL,
|
||||
no in-app link.
|
||||
- **Missing surfaces** — common user need with no in-app path
|
||||
(e.g., "find people to follow" with no `/explore`).
|
||||
- **Audience mismatch** — same URL serving meaningfully different
|
||||
products to anon vs auth.
|
||||
- **Visual inconsistency** — adjacent navbar entries with
|
||||
different treatment (icon vs text, different baselines).
|
||||
- **Mobile hazards** — clusters that will wrap badly under
|
||||
small viewport widths.
|
||||
|
||||
Each observation should be one short paragraph. Each is a
|
||||
question for the user to answer, not a decision you've made.
|
||||
|
||||
11. **List open IA questions**
|
||||
|
||||
Distinct from observations: these are larger directional choices
|
||||
where the answer determines what other observations even matter.
|
||||
Examples: "Should `/` and `/feed` merge for signed-in users?",
|
||||
"Where does an `/explore` page live, if at all?", "Mobile
|
||||
pattern — hamburger? Bottom tab bar?"
|
||||
|
||||
Keep these as bullets the user can answer in one line each.
|
||||
|
||||
12. **Write the doc**
|
||||
|
||||
Output to `docs/information-architecture.md`. Use this top
|
||||
structure:
|
||||
|
||||
```markdown
|
||||
# Information Architecture Review
|
||||
|
||||
*Snapshot date: YYYY-MM-DD.* If the navbar, route table, or feed
|
||||
model has shifted since then, treat this doc as stale and refresh
|
||||
against `apps/journal/app/routes.ts` + `apps/journal/app/root.tsx`.
|
||||
|
||||
A snapshot of where every page lives, who sees it, and how visitors
|
||||
navigate between them. Intended for review — flag anything that
|
||||
doesn't make sense or should change.
|
||||
|
||||
## Apps
|
||||
[...]
|
||||
|
||||
## Journal sitemap
|
||||
### Public surface (logged-out)
|
||||
[...]
|
||||
### Authenticated surface (logged-in)
|
||||
[...]
|
||||
|
||||
### Navigation surfaces
|
||||
[...]
|
||||
|
||||
## Logged-in vs logged-out home
|
||||
[...if `/` does double duty...]
|
||||
|
||||
## [Any "N feed concepts" / duplication sections]
|
||||
[...]
|
||||
|
||||
## Cross-app linking
|
||||
[...]
|
||||
|
||||
## Planner sitemap
|
||||
[...short...]
|
||||
|
||||
## Observations worth discussing
|
||||
[...]
|
||||
|
||||
## Open IA questions
|
||||
[...]
|
||||
```
|
||||
|
||||
Use today's date for the snapshot. Reference the source-of-truth
|
||||
files at the top so the next review knows what to compare against.
|
||||
|
||||
13. **In refresh mode, preserve the user's accumulated decisions**
|
||||
|
||||
The prior doc may already contain:
|
||||
|
||||
- Resolved observations (struck through with a *Resolved: ...*
|
||||
note).
|
||||
- An "Implementation backlog" section with streams.
|
||||
- An "Open exploration" section.
|
||||
|
||||
These are the *user's work*, not the snapshot. Carry them forward
|
||||
untouched unless one is plainly obsolete (e.g., the feature it
|
||||
references no longer exists). When in doubt, leave it and let the
|
||||
user prune.
|
||||
|
||||
If a previously-flagged observation is no longer present in the
|
||||
current code (e.g., it was implemented), update its status note
|
||||
rather than removing it — preserves history.
|
||||
|
||||
14. **Surface a ranked next-action list**
|
||||
|
||||
After writing the doc, summarize in 4–6 lines what changed since
|
||||
the prior review (or what the most actionable observations are if
|
||||
fresh). End with a question: which open IA question does the user
|
||||
want to tackle first?
|
||||
|
||||
Don't start implementation work — this skill is for the
|
||||
*snapshot*. The decisions and the implementation backlog grow
|
||||
through the conversation that follows.
|
||||
|
||||
---
|
||||
|
||||
## What this skill is NOT
|
||||
|
||||
- **Not an implementation skill.** Don't write code, don't open PRs.
|
||||
The doc is the deliverable; decisions and code follow in normal
|
||||
conversation.
|
||||
- **Not a unilateral redesign.** Observations are questions for the
|
||||
user. Don't bake "decisions" into the snapshot — those go in the
|
||||
backlog only when the user has actually answered the question.
|
||||
- **Not a spec change.** OpenSpec specs describe what's shipped; this
|
||||
doc describes the IA *as it stands* with tensions flagged. Any
|
||||
resulting spec updates happen during implementation, not during the
|
||||
review.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Always include the snapshot date at the top of the output doc — IA
|
||||
drifts; future-you needs to know whether to trust the snapshot or
|
||||
refresh it.
|
||||
- Reference the source-of-truth files (`routes.ts`, `root.tsx`) so the
|
||||
next review's diff is mechanical.
|
||||
- Keep observations as questions, not decrees. The user makes the
|
||||
call.
|
||||
- In refresh mode, preserve the user's accumulated decisions verbatim.
|
||||
Only the snapshot sections are yours to rewrite.
|
||||
- Don't invent routes or features. If you can't find evidence for it
|
||||
in the code, don't put it in the snapshot.
|
||||
- Keep it scannable. The doc is for review; verbose explanations bury
|
||||
the signal.
|
||||
|
|
@ -1,216 +0,0 @@
|
|||
---
|
||||
name: spec-drift-review
|
||||
description: Walk every spec in `openspec/specs/`, compare it to the shipped code, and produce a categorized drift report (high/medium/low severity per spec, plus code-without-spec findings and structural suggestions). Use when the user wants to check spec drift, after a chunk of features has shipped, or when planning a spec catch-up PR.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: trails.cool
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
Walk the specs directory + the shipped code, compare them claim by
|
||||
claim, and produce a structured drift report. The report is the
|
||||
deliverable; fixes happen in a follow-up PR after the user has reviewed
|
||||
the findings.
|
||||
|
||||
The goal is to keep `openspec/specs/` honest — specs are useless if they
|
||||
don't describe the actual product, and worse than useless if they
|
||||
contradict it.
|
||||
|
||||
---
|
||||
|
||||
## When to use
|
||||
|
||||
- The user explicitly asks to check spec drift ("are the specs in sync",
|
||||
"review specs against code").
|
||||
- After a multi-feature chunk has shipped without per-feature spec
|
||||
promotion — drift accumulates fastest in catch-up phases.
|
||||
- Before reorganizing the specs directory (split / merge / rename).
|
||||
- When a spec contradicts the codebase and you're not sure which is
|
||||
right.
|
||||
|
||||
---
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Get the lay of the land**
|
||||
|
||||
Run these in parallel:
|
||||
|
||||
```bash
|
||||
ls openspec/specs/
|
||||
ls openspec/changes/ # in-flight work — NOT drift
|
||||
cat openspec/CAPABILITIES.md # if it exists, it's the index
|
||||
openspec list --json # any active changes that explain drift
|
||||
```
|
||||
|
||||
**Important:** any spec referenced in an active openspec change is
|
||||
*expected* to drift from current code — that drift is the work in
|
||||
progress. Note these and exclude them from the report.
|
||||
|
||||
2. **Identify the code-side anchors per spec**
|
||||
|
||||
For each spec at `openspec/specs/<capability>/spec.md`, find the
|
||||
primary code locations that implement it. Most capability specs map
|
||||
to one or more of:
|
||||
|
||||
- **Routes:** `apps/journal/app/routes/*.tsx` / `*.ts` —
|
||||
authoritative for URL behavior, redirects, access gating.
|
||||
- **Server lib:** `apps/journal/app/lib/<topic>.server.ts` — most
|
||||
business logic.
|
||||
- **Schema:** `packages/db/src/schema/journal.ts` — table shapes,
|
||||
visibility values, defaults, indexes.
|
||||
- **i18n:** `packages/i18n/src/locales/{en,de}.ts` — user-facing
|
||||
strings, often telling.
|
||||
- **Tests:** integration tests are a great oracle for the *intended*
|
||||
behavior; mismatch with the spec usually means the spec is stale.
|
||||
|
||||
Don't read every file. Pick the 1–3 anchors per spec that the
|
||||
requirements most plausibly map to.
|
||||
|
||||
3. **Compare claim by claim**
|
||||
|
||||
For each requirement in a spec, ask:
|
||||
|
||||
- **Does the code do this?** If not, is it because the requirement
|
||||
was retired or because it was never shipped?
|
||||
- **Does the code do *more* than this?** New scenarios shipped
|
||||
without a spec update.
|
||||
- **Does the code do this *differently*?** Different URL,
|
||||
different default, different status code, different rule.
|
||||
- **Does this requirement reference a name that no longer exists?**
|
||||
Renamed routes, deleted helpers, removed tables.
|
||||
|
||||
Track findings with severity:
|
||||
|
||||
| Severity | What it means |
|
||||
|----------|---------------|
|
||||
| **High** | Spec actively misleads — claims a behavior the code does not exhibit. A reader implementing against the spec would write the wrong code. |
|
||||
| **Medium** | Spec is incomplete — code has scenarios the spec doesn't describe. Reader gets less information than they should but isn't actively misled. |
|
||||
| **Low** | Wording drift — comments/cross-refs mention a renamed thing, but the requirement statements are still accurate. |
|
||||
|
||||
4. **Find code-without-spec**
|
||||
|
||||
Walk the route tree in `apps/journal/app/routes.ts` and the topic
|
||||
files in `apps/journal/app/lib/`. For each top-level concept ask:
|
||||
|
||||
- Is this concept covered by a spec?
|
||||
- If yes, does the spec mention this surface?
|
||||
- If no, should it be?
|
||||
|
||||
Genuine "no spec" cases are usually: new feature shipped without
|
||||
spec promotion, or piece of infrastructure deemed too internal for a
|
||||
spec. Both are valid; flag the former, leave the latter alone.
|
||||
|
||||
5. **Structural review**
|
||||
|
||||
Spec organization itself can drift:
|
||||
|
||||
- **Specs that grew too big** — multiple unrelated requirements
|
||||
under one spec. Candidates for a split (we previously split
|
||||
`account-settings` into `profile-settings`, `account-management`,
|
||||
`connected-services`).
|
||||
- **Specs that overlap** — the same requirement appears in two
|
||||
specs, or one spec keeps cross-referencing another. Candidate for
|
||||
a merge or a clearer ownership boundary.
|
||||
- **Specs that should exist but don't** — a capability is shipped
|
||||
and substantial enough to merit its own spec but is currently
|
||||
squeezed into another. (We added `sse-broker` and `notifications`
|
||||
this way.)
|
||||
- **`CAPABILITIES.md` drift** — if the index exists, check that
|
||||
every spec dir has an entry and every entry points at a real
|
||||
spec. The index is the easy thing to forget when adding a spec.
|
||||
|
||||
6. **Compose the report**
|
||||
|
||||
Output to the conversation as a markdown structure. Don't write a
|
||||
doc unless the report is unusually large and the user asks for one —
|
||||
most drift reports get acted on inside a single PR and don't need a
|
||||
long-lived artifact.
|
||||
|
||||
Suggested structure:
|
||||
|
||||
```markdown
|
||||
# Spec drift review — YYYY-MM-DD
|
||||
|
||||
**Active changes excluded from this review:**
|
||||
- <name> (touches: <specs>)
|
||||
|
||||
## High-severity drift
|
||||
|
||||
### `<spec-name>`
|
||||
- **Requirement: <name>** says <X>; code at `<file:line>` does <Y>.
|
||||
[link / one-line action]
|
||||
- …
|
||||
|
||||
## Medium-severity drift
|
||||
|
||||
### `<spec-name>`
|
||||
- <description + code anchor>
|
||||
- …
|
||||
|
||||
## Low-severity drift (wording / cross-refs)
|
||||
|
||||
- `<spec-name>`: <description>
|
||||
- …
|
||||
|
||||
## Code-without-spec
|
||||
|
||||
- <feature> at `<file>` — should this be in <spec-name>, or a new
|
||||
spec? Recommendation: <…>
|
||||
|
||||
## Structural suggestions
|
||||
|
||||
- Split: <spec-name> → <new-1>, <new-2>
|
||||
- Merge: <spec-a> + <spec-b> → <new>
|
||||
- New spec: <name> covering <area>
|
||||
- `CAPABILITIES.md` updates: <list>
|
||||
```
|
||||
|
||||
7. **Propose next actions, then stop**
|
||||
|
||||
End the review with three concrete options the user can pick from:
|
||||
|
||||
- **Ship a catch-up PR for everything** — works when drift is
|
||||
mostly low/medium and the fix is mechanical.
|
||||
- **Fix high-severity first, defer the rest** — works when high
|
||||
items are urgent and the rest can wait for natural per-feature
|
||||
spec updates.
|
||||
- **Restructure first, then catch up** — works when structural
|
||||
suggestions (split / merge / new spec) are large enough that
|
||||
fixing claims inside the wrong spec shape would just have to be
|
||||
redone.
|
||||
|
||||
Don't pick for them. Don't start fixing yet.
|
||||
|
||||
---
|
||||
|
||||
## What this skill is NOT
|
||||
|
||||
- **Not a fixer.** This skill produces a report. Apply happens after
|
||||
the user has decided which findings to act on.
|
||||
- **Not a CI check.** It's interactive — judgment calls (severity,
|
||||
splits, merges) are part of the value, not an automation target.
|
||||
- **Not for in-flight work.** Active openspec changes legitimately
|
||||
cause spec/code mismatch; flag them in the "excluded" section and
|
||||
move on.
|
||||
- **Not a code review.** The question is "does the spec match the
|
||||
code", not "is the code good." Code-quality observations belong in
|
||||
PR review, not here.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Always start by reading active openspec changes — drift caused by
|
||||
in-flight work is not drift.
|
||||
- Don't write spec edits during the review. The user picks which
|
||||
findings to act on; edits happen after.
|
||||
- When code and spec disagree, the prevailing rule on this project is
|
||||
**code is source of truth** unless the user says otherwise. Surface
|
||||
the conflict; don't preemptively decide.
|
||||
- Skip generated/scaffold files (e.g. `.react-router/types/**`) — they
|
||||
are derived, not product.
|
||||
- Don't pad the report with low-severity wording drift unless the user
|
||||
asks for it. Concentrate signal.
|
||||
- Keep observations specific (file + line + claim), not abstract. A
|
||||
finding without an anchor isn't actionable.
|
||||
|
|
@ -1,15 +1,12 @@
|
|||
---
|
||||
name: "OPSX: Apply"
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
|
@ -29,7 +26,6 @@ Implement tasks from an OpenSpec change.
|
|||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
|
@ -39,7 +35,7 @@ Implement tasks from an OpenSpec change.
|
|||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema)
|
||||
- Context file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
|
@ -51,7 +47,7 @@ Implement tasks from an OpenSpec change.
|
|||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
|
|
|||
|
|
@ -1,15 +1,12 @@
|
|||
---
|
||||
name: "OPSX: Archive"
|
||||
description: Archive a completed change in the experimental workflow
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, archive, experimental]
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx:archive` (e.g., `/opsx:archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
|
@ -29,7 +26,6 @@ Archive a completed change in the experimental workflow.
|
|||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
|
|
@ -52,7 +48,7 @@ Archive a completed change in the experimental workflow.
|
|||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
|
|
@ -67,19 +63,19 @@ Archive a completed change in the experimental workflow.
|
|||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
|
@ -98,7 +94,7 @@ Archive a completed change in the experimental workflow.
|
|||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
|
|
@ -111,7 +107,7 @@ All artifacts complete. All tasks complete.
|
|||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
|
|
@ -124,7 +120,7 @@ All artifacts complete. All tasks complete.
|
|||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
|
|
@ -141,7 +137,7 @@ Review the archive if this was not intentional.
|
|||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
name: "OPSX: Explore"
|
||||
description: "Enter explore mode - think through ideas, investigate problems, clarify requirements"
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, explore, experimental, thinking]
|
||||
---
|
||||
|
|
@ -12,8 +11,6 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
|
|||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
|
|
@ -62,10 +59,10 @@ Depending on what the user brings, you might:
|
|||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
|
|
@ -110,10 +107,11 @@ Think freely. When insights crystallize, you might offer:
|
|||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
|
|
@ -121,14 +119,14 @@ If the user mentions a change or you detect one is relevant:
|
|||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
---
|
||||
name: "OPSX: Propose"
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
allowed-tools: Bash(openspec:*)
|
||||
category: Workflow
|
||||
tags: [workflow, artifacts, experimental]
|
||||
---
|
||||
|
|
@ -17,8 +16,6 @@ When ready to implement, run /opsx:apply
|
|||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
|
@ -36,7 +33,7 @@ When ready to implement, run /opsx:apply
|
|||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
|
|
@ -45,7 +42,6 @@ When ready to implement, run /opsx:apply
|
|||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
|
|
@ -63,10 +59,10 @@ When ready to implement, run /opsx:apply
|
|||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
../.agents/skills
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
|
@ -33,7 +30,6 @@ Implement tasks from an OpenSpec change.
|
|||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- `planningHome`, `changeRoot`, and `actionContext`: planning scope and edit constraints
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
|
@ -43,7 +39,7 @@ Implement tasks from an OpenSpec change.
|
|||
```
|
||||
|
||||
This returns:
|
||||
- `contextFiles`: artifact ID -> array of concrete file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
|
@ -55,7 +51,7 @@ Implement tasks from an OpenSpec change.
|
|||
|
||||
4. **Read context files**
|
||||
|
||||
Read every file path listed under `contextFiles` from the apply instructions output.
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
|
@ -33,7 +30,6 @@ Archive a completed change in the experimental workflow.
|
|||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
|
|
@ -56,7 +52,7 @@ Archive a completed change in the experimental workflow.
|
|||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Use `artifactPaths.specs.existingOutputPaths` from status JSON to check for delta specs. If none exist, proceed without sync prompt.
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
|
|
@ -71,19 +67,19 @@ Archive a completed change in the experimental workflow.
|
|||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create an `archive` directory under `planningHome.changesDir` if it doesn't exist:
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p "<planningHome.changesDir>/archive"
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move `changeRoot` to the archive directory
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv "<changeRoot>" "<planningHome.changesDir>/archive/YYYY-MM-DD-<name>"
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
|
@ -102,7 +98,7 @@ Archive a completed change in the experimental workflow.
|
|||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** the archive path derived from `planningHome.changesDir`/YYYY-MM-DD-<name>/
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
|
@ -16,8 +15,6 @@ Enter explore mode. Think deeply. Visualize freely. Follow the conversation wher
|
|||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
|
@ -59,10 +56,10 @@ Depending on what the user brings, you might:
|
|||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
|
|
@ -105,10 +102,11 @@ Think freely. When insights crystallize, you might offer:
|
|||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Resolve and read existing artifacts for context**
|
||||
- Run `openspec status --change "<name>" --json`.
|
||||
- Use `changeRoot`, `artifactPaths`, and `actionContext` from the status JSON.
|
||||
- Read existing files from `artifactPaths.<artifact>.existingOutputPaths`.
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
|
|
@ -116,14 +114,14 @@ If the user mentions a change or you detect one is relevant:
|
|||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|----------------------------|--------------------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
|
|
@ -229,7 +227,7 @@ User: A CLI tool that tracks local dev environments
|
|||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
allowed-tools: Bash(openspec:*)
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.6.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
|
@ -21,8 +20,6 @@ When ready to implement, run /opsx:apply
|
|||
|
||||
---
|
||||
|
||||
**Store selection:** If the user names a store (a store is a standalone OpenSpec repo registered on this machine) or the work lives in one, run `openspec store list --json` to discover registered store ids, then pass `--store <id>` on the commands that read or write specs and changes (`new change`, `status`, `instructions`, `list`, `show`, `validate`, `archive`, `doctor`, `context`). Other commands do not take the flag. Hints printed by commands already carry the flag; keep it on follow-ups. Without a store, commands act on the nearest local `openspec/` root.
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
|
@ -40,7 +37,7 @@ When ready to implement, run /opsx:apply
|
|||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change in the planning home resolved by the CLI with `.openspec.yaml`.
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
|
|
@ -49,7 +46,6 @@ When ready to implement, run /opsx:apply
|
|||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
- `planningHome`, `changeRoot`, `artifactPaths`, and `actionContext`: path and scope context. Use these instead of assuming repo-local paths.
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
|
|
@ -67,10 +63,10 @@ When ready to implement, run /opsx:apply
|
|||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `resolvedOutputPath`: Resolved path or pattern to write the artifact
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure and write it to `resolvedOutputPath`
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# Keep Docker build contexts small and hermetic. CI builds from a clean
|
||||
# checkout so this mostly matters for local builds (e2e/federation
|
||||
# harness), where node_modules would otherwise bloat the context to
|
||||
# gigabytes — and worse, `COPY . .` would overlay host-installed
|
||||
# node_modules over the image's own pnpm install.
|
||||
**/node_modules
|
||||
**/.turbo
|
||||
**/build
|
||||
**/.react-router
|
||||
.git
|
||||
e2e/results
|
||||
playwright-report
|
||||
test-results
|
||||
**/*.log
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# Root-level local dev defaults.
|
||||
# Copy to `.env.development` (gitignored) to override.
|
||||
# All values here work out of the box — no changes needed for standard local dev.
|
||||
|
||||
DATABASE_URL=postgres://trails:trails@localhost:5432/trails
|
||||
BROUTER_URL=http://localhost:17777
|
||||
|
||||
# Change these for any environment that is not purely local.
|
||||
JWT_SECRET=dev-secret-not-for-production
|
||||
SESSION_SECRET=dev-secret-not-for-production
|
||||
18
.github/dependabot.yml
vendored
18
.github/dependabot.yml
vendored
|
|
@ -20,22 +20,10 @@ updates:
|
|||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@types/node"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# These packages are version-pinned by the Expo SDK (see
|
||||
# expo/bundledNativeModules.json) — upgrade them via an Expo SDK
|
||||
# bump / `npx expo install --fix`, never on their own. Dependabot
|
||||
# bumping them past the SDK's expected version broke the native
|
||||
# build (expo-modules-core macro mismatch, June 2026) because CI
|
||||
# never compiles native code. `react` stays unignored: the web
|
||||
# apps own its version via the workspace catalog, and apps/mobile
|
||||
# excludes it from expo version checks.
|
||||
# react-native is pinned by the Expo SDK — upgrade it via an Expo
|
||||
# SDK bump, not on its own. Community react-native-* libraries are
|
||||
# intentionally NOT ignored here; they can be bumped independently.
|
||||
- dependency-name: "react-native"
|
||||
- dependency-name: "react-native-gesture-handler"
|
||||
- dependency-name: "react-native-reanimated"
|
||||
- dependency-name: "react-native-safe-area-context"
|
||||
- dependency-name: "react-native-screens"
|
||||
- dependency-name: "react-native-worklets"
|
||||
- dependency-name: "@sentry/react-native"
|
||||
- dependency-name: "jest-expo"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
|
|
|
|||
95
.github/workflows/cd-apps.yml
vendored
95
.github/workflows/cd-apps.yml
vendored
|
|
@ -13,15 +13,6 @@ concurrency:
|
|||
group: deploy-apps
|
||||
cancel-in-progress: true
|
||||
|
||||
# Public Sentry DSNs for the trails.cool flagship instance. Public by
|
||||
# design — Sentry DSNs are transmitted unencrypted from the client JS
|
||||
# bundle, embedding them in this workflow is no worse than embedding
|
||||
# them in the runtime env. Self-hosted forks should either replace
|
||||
# these with their own DSNs or remove the lines to ship without Sentry.
|
||||
env:
|
||||
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
|
||||
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
|
||||
|
||||
jobs:
|
||||
build-images:
|
||||
name: Build & Push Docker Images
|
||||
|
|
@ -34,7 +25,7 @@ jobs:
|
|||
matrix:
|
||||
app: [journal, planner]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
|
|
@ -57,12 +48,8 @@ jobs:
|
|||
tags: |
|
||||
ghcr.io/trails-cool/${{ matrix.app }}:latest
|
||||
ghcr.io/trails-cool/${{ matrix.app }}:${{ github.sha }}
|
||||
# VITE_SENTRY_DSN bakes the client-side DSN into the journal's
|
||||
# built bundle. Only journal has a client Sentry init; planner
|
||||
# ignores the build-arg if present.
|
||||
build-args: |
|
||||
SENTRY_RELEASE=${{ github.sha }}
|
||||
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
|
||||
secrets: |
|
||||
SENTRY_AUTH_TOKEN=/tmp/sentry_token
|
||||
|
||||
|
|
@ -72,7 +59,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Decrypt secrets
|
||||
run: |
|
||||
|
|
@ -83,16 +70,6 @@ jobs:
|
|||
echo "DOMAIN=trails.cool" >> infrastructure/app.env
|
||||
# Flagship marker — see cd-infra.yml for what this gates.
|
||||
echo "IS_FLAGSHIP=true" >> infrastructure/app.env
|
||||
# Federation on (social-federation rollout 12.5, flipped
|
||||
# 2026-06-07 after the staging + Mastodon soak). The
|
||||
# FEDERATION_KEY_ENCRYPTION_KEY comes from the SOPS env
|
||||
# decrypted above. Rollback: delete these two lines, merge,
|
||||
# rerun cd-apps — instant off, federation surfaces 404.
|
||||
echo "FEDERATION_ENABLED=true" >> infrastructure/app.env
|
||||
echo "FEDERATION_LOG_LEVEL=info" >> infrastructure/app.env
|
||||
# Sentry DSNs (public — see workflow top-level env for context).
|
||||
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL" >> infrastructure/app.env
|
||||
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER" >> infrastructure/app.env
|
||||
|
||||
- name: Copy files to server
|
||||
uses: appleboy/scp-action@v1
|
||||
|
|
@ -100,7 +77,7 @@ jobs:
|
|||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "infrastructure/docker-compose.yml,infrastructure/caddy"
|
||||
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile"
|
||||
target: /opt/trails-cool
|
||||
strip_components: 1
|
||||
|
||||
|
|
@ -121,10 +98,6 @@ jobs:
|
|||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
# Abort the deploy on the first failure. Without this, a failed
|
||||
# schema push deploys new code against an old schema (the
|
||||
# 2026-06-06 schema-drift incident).
|
||||
set -euo pipefail
|
||||
cd /opt/trails-cool
|
||||
|
||||
# Login to ghcr.io
|
||||
|
|
@ -133,69 +106,17 @@ jobs:
|
|||
|
||||
# Pull and deploy app containers
|
||||
docker compose --env-file app.env pull journal planner
|
||||
# Hand-written data migrations (idempotent) run BEFORE drizzle-kit
|
||||
# push so unique-key reshapes can collapse duplicate rows first.
|
||||
docker compose --env-file app.env run --rm journal node --experimental-strip-types /app/packages/db/src/migrate-data.ts
|
||||
# drizzle-kit exits 0 even when it aborts on an interactive
|
||||
# prompt it can't show (no TTY in CI) — that exact lie hid a
|
||||
# month of staging schema drift. Treat any Error in its output
|
||||
# as a failed deploy.
|
||||
docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
|
||||
if grep -q "Error:" /tmp/drizzle-push.log; then
|
||||
echo "drizzle-kit push reported an error — failing the deploy"
|
||||
exit 1
|
||||
fi
|
||||
docker compose --env-file app.env run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force
|
||||
# --remove-orphans cleans up containers whose service was deleted
|
||||
# from the compose file, matching cd-infra's behaviour.
|
||||
docker compose --env-file app.env up -d --remove-orphans journal planner
|
||||
|
||||
# Gate on container health: a deploy that leaves journal or
|
||||
# planner unhealthy must fail loudly, not report green.
|
||||
for svc in journal planner; do
|
||||
for i in $(seq 1 24); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' "trails-cool-$svc-1" 2>/dev/null || echo missing)
|
||||
[ "$status" = "healthy" ] && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "$svc did not become healthy (last status: $status)"
|
||||
docker compose --env-file app.env logs "$svc" --tail 50 || true
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Reload Caddy with the Caddyfile we just scp'd. cd-apps
|
||||
# ships infrastructure/Caddyfile alongside docker-compose.yml
|
||||
# (see scp step above), but containers don't auto-pick-up
|
||||
# config changes. cd-infra reloads Caddy as part of its
|
||||
# deploy; cd-apps did NOT, which meant any Caddyfile change
|
||||
# touching only `apps/`/`packages/` paths sat on disk
|
||||
# unapplied until the next cd-infra run. The reload is
|
||||
# idempotent (Caddy validates first, swaps live, no
|
||||
# downtime) so doing it on every cd-apps deploy is safe
|
||||
# even when Caddyfile is unchanged. `|| true` keeps the
|
||||
# deploy from failing if Caddy itself is unhealthy.
|
||||
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
|
||||
|
||||
# Clean up (best-effort). This runs AFTER the containers are
|
||||
# swapped + Caddy reloaded, so the deploy already succeeded —
|
||||
# a transient "a prune operation is already running" collision
|
||||
# with a concurrent deploy/disk-maintenance prune must NOT fail
|
||||
# an otherwise-green deploy. disk-maintenance.yml is the real
|
||||
# image-prune safety net.
|
||||
docker image prune -af || true
|
||||
# Clean up
|
||||
docker image prune -af
|
||||
docker compose ps
|
||||
|
||||
# Annotate deploy in Grafana. GRAFANA_SERVICE_TOKEN lives
|
||||
# in secrets.infra.env (decrypted by cd-infra.yml into the
|
||||
# merged /opt/trails-cool/.env on the server). cd-apps's
|
||||
# own app.env intentionally does NOT carry it — apps don't
|
||||
# need it at runtime. So we read from the merged `.env`
|
||||
# that cd-infra populated. If cd-infra has never run on
|
||||
# this host, .env may not exist; the `2>/dev/null` and
|
||||
# the `if -n` guard make the annotation a silent no-op
|
||||
# rather than a deploy failure in that case.
|
||||
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env 2>/dev/null | cut -d= -f2-)
|
||||
# Annotate deploy in Grafana
|
||||
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2- 2>/dev/null)
|
||||
if [ -n "$GRAFANA_TOKEN" ]; then
|
||||
docker compose exec -T grafana curl -sf -X POST \
|
||||
-H "Authorization: Bearer $GRAFANA_TOKEN" \
|
||||
|
|
|
|||
7
.github/workflows/cd-brouter.yml
vendored
7
.github/workflows/cd-brouter.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
|
|
@ -42,7 +42,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
environment: infra
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Decrypt shared secret
|
||||
id: decrypt
|
||||
|
|
@ -87,7 +87,6 @@ jobs:
|
|||
TARBALL_B64=$(tar -C infrastructure/brouter-host -czf - \
|
||||
docker-compose.yml Caddyfile promtail-config.yml \
|
||||
download-segments.sh .env \
|
||||
poi-extract \
|
||||
| base64 -w0)
|
||||
|
||||
# One SSH session: untar, (re)start containers, report status
|
||||
|
|
@ -100,7 +99,7 @@ jobs:
|
|||
set -euo pipefail
|
||||
mkdir -p ~/brouter && cd ~/brouter
|
||||
echo "$TARBALL_B64" | base64 -d | tar -xzf -
|
||||
chmod +x download-segments.sh poi-extract/poi-extract.sh poi-extract/to-ndjson.py
|
||||
chmod +x download-segments.sh
|
||||
|
||||
# Segment seeding is a one-shot operator task (~10 GB, a few
|
||||
# minutes). CD must not rerun it on every deploy.
|
||||
|
|
|
|||
70
.github/workflows/cd-infra.yml
vendored
70
.github/workflows/cd-infra.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
environment: infra
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Decrypt secrets
|
||||
run: |
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "infrastructure/docker-compose.yml,infrastructure/caddy,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards,infrastructure/scripts"
|
||||
source: "infrastructure/docker-compose.yml,infrastructure/Caddyfile,infrastructure/prometheus/prometheus.yml,infrastructure/loki/loki-config.yml,infrastructure/promtail/promtail-config.yml,infrastructure/postgres/queries.yml,infrastructure/postgres/init-grafana-user.sql,infrastructure/grafana/provisioning,infrastructure/grafana/dashboards"
|
||||
target: /opt/trails-cool
|
||||
strip_components: 1
|
||||
|
||||
|
|
@ -64,11 +64,6 @@ jobs:
|
|||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
# Abort on first failure. The 2026-06-06/07 outage: a network
|
||||
# recreation stopped postgres, a later step failed, and the
|
||||
# deploy left production down for ~9h while the job's partial
|
||||
# progress looked plausible. Fail fast, verify health at the end.
|
||||
set -euo pipefail
|
||||
cd /opt/trails-cool
|
||||
|
||||
# .env was placed by the SCP step (decrypted app + infra secrets)
|
||||
|
|
@ -84,77 +79,20 @@ jobs:
|
|||
docker compose exec -T postgres psql -U trails -d trails -c "ALTER ROLE grafana_reader PASSWORD '$GRAFANA_DB_PW'" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Capture whether prometheus was recreated. A fresh container already
|
||||
# reads the new config on startup; sending it SIGHUP immediately can
|
||||
# kill Prometheus 3.10 during early boot (exit 2 observed on
|
||||
# 2026-06-09).
|
||||
PROMETHEUS_BEFORE_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
|
||||
|
||||
# Full restart: gh workflow run cd-infra.yml -f restart_all=true
|
||||
if [ "${{ github.event.inputs.restart_all }}" = "true" ]; then
|
||||
docker compose --env-file .env up -d --remove-orphans
|
||||
else
|
||||
# Restart infra services (config reloads handled below).
|
||||
# Restart infra services (except Caddy — just reload its config).
|
||||
# --remove-orphans cleans up containers whose service was deleted
|
||||
# from the compose file (e.g., the flagship `brouter` removal in
|
||||
# PR #297 left an orphan that had to be removed by hand).
|
||||
docker compose --env-file .env up -d --remove-orphans postgres prometheus loki promtail grafana postgres-exporter node-exporter cadvisor
|
||||
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
fi
|
||||
|
||||
PROMETHEUS_AFTER_ID=$(docker inspect -f '{{.Id}}' trails-cool-prometheus-1 2>/dev/null || true)
|
||||
|
||||
# Apply config-only changes that `up -d` skips — it recreates a
|
||||
# container only when its compose *definition* changes, not when a
|
||||
# mounted config file's content changes. The configs are mounted as
|
||||
# directories (not single files), so a reload/restart re-reads the
|
||||
# freshly scp'd file; a single-file mount would have pinned the old
|
||||
# inode. Prometheus hot-reloads on SIGHUP (zero downtime) only when
|
||||
# the same container stayed up; a recreated container already loaded
|
||||
# the new config on startup. Loki and Promtail reload their main
|
||||
# config only on restart; Caddy reloads gracefully (validates, swaps
|
||||
# live, no downtime).
|
||||
if [ -n "$PROMETHEUS_BEFORE_ID" ] && [ "$PROMETHEUS_BEFORE_ID" = "$PROMETHEUS_AFTER_ID" ]; then
|
||||
docker compose --env-file .env kill -s SIGHUP prometheus
|
||||
fi
|
||||
docker compose --env-file .env restart loki promtail
|
||||
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
|
||||
docker compose ps
|
||||
|
||||
# Gate on the stack actually being up: postgres healthy, journal
|
||||
# back to healthy after the DB bounce, and Prometheus answering
|
||||
# /-/ready. A deploy that leaves any of them down must fail loudly
|
||||
# (see the 2026-06-06/07 outage, plus the 2026-06-09 Prometheus
|
||||
# startup/HUP regression).
|
||||
for ctr in trails-cool-postgres-1 trails-cool-journal-1; do
|
||||
for i in $(seq 1 36); do
|
||||
status=$(docker inspect -f '{{.State.Health.Status}}' "$ctr" 2>/dev/null || echo missing)
|
||||
[ "$status" = "healthy" ] && break
|
||||
sleep 5
|
||||
done
|
||||
if [ "$status" != "healthy" ]; then
|
||||
echo "$ctr did not become healthy (last status: $status)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
PROMETHEUS_READY=
|
||||
for i in $(seq 1 36); do
|
||||
prom_status=$(docker inspect -f '{{.State.Status}}' trails-cool-prometheus-1 2>/dev/null || echo missing)
|
||||
if [ "$prom_status" = "running" ]; then
|
||||
prom_ip=$(docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' trails-cool-prometheus-1)
|
||||
if curl -sf "http://$prom_ip:9090/-/ready" >/dev/null; then
|
||||
PROMETHEUS_READY=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
if [ -z "$PROMETHEUS_READY" ]; then
|
||||
echo "trails-cool-prometheus-1 did not become ready (last status: $prom_status)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Annotate deploy in Grafana
|
||||
GRAFANA_TOKEN=$(grep GRAFANA_SERVICE_TOKEN .env | cut -d= -f2-)
|
||||
if [ -n "$GRAFANA_TOKEN" ]; then
|
||||
|
|
|
|||
520
.github/workflows/cd-staging.yml
vendored
520
.github/workflows/cd-staging.yml
vendored
|
|
@ -1,520 +0,0 @@
|
|||
name: CD Staging
|
||||
|
||||
# Builds, deploys, and tears down the persistent staging stack and per-PR
|
||||
# preview environments. See `openspec/changes/staging-environments/` for the
|
||||
# design (decisions on shared Postgres, port allocation, per-PR Caddyfile
|
||||
# snippets) and CLAUDE.md "Staging & Previews" for the operator-facing view.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "apps/**"
|
||||
- "packages/**"
|
||||
- "pnpm-lock.yaml"
|
||||
# Also redeploy when the staging plumbing itself changes, so a port
|
||||
# bump or compose edit doesn't sit unapplied until the next apps/ push.
|
||||
- "infrastructure/docker-compose.staging.yml"
|
||||
- ".github/workflows/cd-staging.yml"
|
||||
pull_request:
|
||||
# `labeled`/`unlabeled` so toggling the `preview` label on an existing PR
|
||||
# starts / tears down its preview (see the opt-in gate on the jobs below).
|
||||
types: [opened, synchronize, reopened, closed, labeled, unlabeled]
|
||||
paths:
|
||||
- "apps/**"
|
||||
- "packages/**"
|
||||
- "pnpm-lock.yaml"
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Per-target concurrency: persistent staging deploys serialize against
|
||||
# themselves, each PR's preview lifecycle serializes against itself.
|
||||
concurrency:
|
||||
group: staging-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || 'main' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Public Sentry DSNs (same as cd-apps.yml). See that workflow for the
|
||||
# "public by design" rationale.
|
||||
env:
|
||||
SENTRY_DSN_JOURNAL: "https://a32ffcc575d34be072e91b20f247eeee@o4509530546634752.ingest.de.sentry.io/4509530555547728"
|
||||
SENTRY_DSN_PLANNER: "https://5215134cd78d5e6c199e29300b8425af@o4509530546634752.ingest.de.sentry.io/4511102546608208"
|
||||
|
||||
jobs:
|
||||
# ── Build ─────────────────────────────────────────────────────────────
|
||||
# Tags:
|
||||
# main push → :staging + :<sha>
|
||||
# PR open/sync/reopen → :pr-<N> + :pr-<N>-<sha>
|
||||
# Skipped entirely on PR close (teardown doesn't need new images).
|
||||
build-images:
|
||||
name: Build & Push Docker Images
|
||||
# PR previews are opt-in to keep flagship disk in check (each preview is a
|
||||
# journal container + database): build a PR's images only when it carries
|
||||
# the `preview` label or a `<!-- preview -->` marker in its description.
|
||||
# Main-push / dispatch always build.
|
||||
if: >
|
||||
github.event_name != 'pull_request' ||
|
||||
(github.event.action != 'closed' &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.body, '<!-- preview -->')))
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
app: [journal, planner]
|
||||
outputs:
|
||||
tag_primary: ${{ steps.tags.outputs.primary }}
|
||||
tag_sha: ${{ steps.tags.outputs.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: tags
|
||||
name: Compute image tags
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
PRIMARY="pr-${{ github.event.number }}"
|
||||
SHA="pr-${{ github.event.number }}-${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
PRIMARY="staging"
|
||||
SHA="${{ github.sha }}"
|
||||
fi
|
||||
echo "primary=$PRIMARY" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Decrypt Sentry auth token
|
||||
run: |
|
||||
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
|
||||
chmod +x sops-v3.9.4.linux.amd64
|
||||
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > /tmp/secrets.env
|
||||
grep SENTRY_AUTH_TOKEN /tmp/secrets.env | cut -d= -f2- | tr -d '\n' > /tmp/sentry_token
|
||||
|
||||
- uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: apps/${{ matrix.app }}/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.primary }}
|
||||
ghcr.io/trails-cool/${{ matrix.app }}:${{ steps.tags.outputs.sha }}
|
||||
build-args: |
|
||||
SENTRY_RELEASE=${{ steps.tags.outputs.sha }}
|
||||
VITE_SENTRY_DSN=${{ matrix.app == 'journal' && env.SENTRY_DSN_JOURNAL || '' }}
|
||||
secrets: |
|
||||
SENTRY_AUTH_TOKEN=/tmp/sentry_token
|
||||
|
||||
# ── Deploy persistent staging (main push or manual dispatch) ─────────
|
||||
deploy-staging:
|
||||
name: Deploy Staging
|
||||
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch'
|
||||
needs: [build-images]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Decrypt secrets
|
||||
run: |
|
||||
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
|
||||
chmod +x sops-v3.9.4.linux.amd64
|
||||
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging.env
|
||||
{
|
||||
echo "DOMAIN=staging.trails.cool"
|
||||
echo "STAGING_DATABASE=trails_staging"
|
||||
# Loki binds 10.0.0.2:3100 on the vSwitch interface, so the
|
||||
# staging stack can't share 3100 — see CLAUDE.md "Staging &
|
||||
# Previews" port table.
|
||||
echo "JOURNAL_HOST_PORT=3110"
|
||||
echo "PLANNER_HOST_PORT=3111"
|
||||
echo "JOURNAL_IMAGE_TAG=staging"
|
||||
echo "PLANNER_IMAGE_TAG=staging"
|
||||
echo "SENTRY_RELEASE=${{ github.sha }}"
|
||||
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
|
||||
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
|
||||
# Federation soak on persistent staging only (social-federation
|
||||
# rollout 12.2). FEDERATION_KEY_ENCRYPTION_KEY comes from the
|
||||
# SOPS env decrypted above; previews never set this flag.
|
||||
echo "FEDERATION_ENABLED=true"
|
||||
# Verbose Fedify logs during the soak (signature verification
|
||||
# detail). Dial down to info once federation is proven out.
|
||||
echo "FEDERATION_LOG_LEVEL=debug"
|
||||
} >> infrastructure/staging.env
|
||||
|
||||
- name: Copy compose file + env to server
|
||||
uses: appleboy/scp-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging.env"
|
||||
target: /opt/trails-cool
|
||||
strip_components: 1
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/trails-cool
|
||||
|
||||
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN staging.env | cut -d= -f2-)
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
|
||||
|
||||
# Bootstrap the shared network so cd-staging works regardless of
|
||||
# whether cd-infra has already run with the new docker-compose.yml.
|
||||
# Once cd-infra runs, postgres is permanently joined via compose;
|
||||
# until then, attach it imperatively.
|
||||
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
|
||||
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
|
||||
if [ -n "$PG_CONTAINER" ]; then
|
||||
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Ensure trails_staging database exists with postgis. Production
|
||||
# init scripts only run on first data-dir init, so a freshly
|
||||
# created database has no extensions.
|
||||
docker compose exec -T postgres psql -U trails -d postgres -tAc \
|
||||
"SELECT 1 FROM pg_database WHERE datname='trails_staging'" \
|
||||
| grep -q 1 \
|
||||
|| docker compose exec -T postgres createdb -U trails trails_staging
|
||||
docker compose exec -T postgres psql -U trails -d trails_staging -c \
|
||||
"CREATE EXTENSION IF NOT EXISTS postgis"
|
||||
|
||||
# Pull and deploy staging containers (journal + planner via "persistent" profile)
|
||||
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent pull
|
||||
# drizzle-kit exits 0 even when it aborts on an interactive
|
||||
# prompt (no TTY in CI) — set -e alone can't catch it. That lie
|
||||
# hid a month of staging schema drift (2026-06-06 incident).
|
||||
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
|
||||
if grep -q "Error:" /tmp/drizzle-push.log; then
|
||||
echo "drizzle-kit push reported an error — failing the deploy"
|
||||
exit 1
|
||||
fi
|
||||
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent up -d --remove-orphans
|
||||
|
||||
# Reload Caddy so new staging routes (or Caddyfile changes shipped
|
||||
# via cd-infra) are live. Idempotent.
|
||||
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
|
||||
|
||||
# Reclaim superseded image layers. cd-apps prunes after its own
|
||||
# deploys, but a day of staging/preview deploys while cd-apps is
|
||||
# red can fill the disk on its own (2026-06-07: 100% full,
|
||||
# postgres down). The 1h filter avoids racing layers another
|
||||
# in-flight deploy just pulled.
|
||||
docker image prune -af --filter "until=1h" || true
|
||||
|
||||
docker compose -f docker-compose.staging.yml -p trails-staging --env-file staging.env --profile persistent ps
|
||||
|
||||
# ── PR preview deploy ────────────────────────────────────────────────
|
||||
deploy-preview:
|
||||
name: Deploy PR Preview
|
||||
# Opt-in only (see build-images): the `preview` label or a `<!-- preview -->`
|
||||
# marker in the PR body. Also skips GH-Actions-only Dependabot PRs — there
|
||||
# is no app image to preview.
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action != 'closed' &&
|
||||
!startsWith(github.head_ref, 'dependabot/github_actions/') &&
|
||||
(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.body, '<!-- preview -->'))
|
||||
needs: [build-images]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- id: ports
|
||||
name: Compute preview ports + project name
|
||||
run: |
|
||||
PR=${{ github.event.number }}
|
||||
# journal = 3200 + 2N, planner unused (PR previews are journal-only)
|
||||
JOURNAL_PORT=$((3200 + 2 * PR))
|
||||
PLANNER_PORT=$((3201 + 2 * PR))
|
||||
echo "pr=$PR" >> "$GITHUB_OUTPUT"
|
||||
echo "journal_port=$JOURNAL_PORT" >> "$GITHUB_OUTPUT"
|
||||
echo "planner_port=$PLANNER_PORT" >> "$GITHUB_OUTPUT"
|
||||
echo "host=pr-$PR.staging.trails.cool" >> "$GITHUB_OUTPUT"
|
||||
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
|
||||
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Decrypt secrets + assemble env
|
||||
run: |
|
||||
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
|
||||
chmod +x sops-v3.9.4.linux.amd64
|
||||
# Use a per-PR filename so concurrent SCP transfers don't overwrite each other.
|
||||
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
|
||||
{
|
||||
echo "DOMAIN=${{ steps.ports.outputs.host }}"
|
||||
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
|
||||
echo "JOURNAL_HOST_PORT=${{ steps.ports.outputs.journal_port }}"
|
||||
echo "PLANNER_HOST_PORT=${{ steps.ports.outputs.planner_port }}"
|
||||
echo "JOURNAL_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
|
||||
echo "PLANNER_IMAGE_TAG=pr-${{ steps.ports.outputs.pr }}"
|
||||
# PR-preview journals all share the persistent staging planner.
|
||||
echo "PLANNER_URL=https://planner.staging.trails.cool"
|
||||
echo "SENTRY_RELEASE=${{ github.event.pull_request.head.sha }}"
|
||||
echo "SENTRY_DSN_JOURNAL=$SENTRY_DSN_JOURNAL"
|
||||
echo "SENTRY_DSN_PLANNER=$SENTRY_DSN_PLANNER"
|
||||
# Federation on previews too (social-federation rollout 12.4):
|
||||
# makes any PR preview a second live trails instance that
|
||||
# persistent staging can follow across — the trails-to-trails
|
||||
# soak surface. FEDERATION_KEY_ENCRYPTION_KEY comes from the
|
||||
# SOPS env decrypted above. Preview teardown orphans remote
|
||||
# follower rows on the other side; remotes handle dead
|
||||
# instances via ordinary delivery-failure expiry.
|
||||
echo "FEDERATION_ENABLED=true"
|
||||
echo "FEDERATION_LOG_LEVEL=debug"
|
||||
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
|
||||
|
||||
- name: Generate per-PR Caddyfile snippet
|
||||
run: |
|
||||
mkdir -p infrastructure/sites
|
||||
cat > infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile <<EOF
|
||||
# Auto-generated by cd-staging.yml for PR ${{ steps.ports.outputs.pr }}. Do not edit.
|
||||
${{ steps.ports.outputs.host }} {
|
||||
import security_headers
|
||||
import block_scanners
|
||||
log {
|
||||
output stdout
|
||||
format json
|
||||
}
|
||||
reverse_proxy host.docker.internal:${{ steps.ports.outputs.journal_port }} {
|
||||
lb_try_duration 30s
|
||||
lb_try_interval 250ms
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Copy files to server
|
||||
uses: appleboy/scp-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env,infrastructure/sites/pr-${{ steps.ports.outputs.pr }}.caddyfile"
|
||||
target: /opt/trails-cool
|
||||
strip_components: 1
|
||||
|
||||
- name: Deploy preview via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
env:
|
||||
PR: ${{ steps.ports.outputs.pr }}
|
||||
PROJECT: ${{ steps.ports.outputs.project }}
|
||||
DB: ${{ steps.ports.outputs.database }}
|
||||
JOURNAL_PORT: ${{ steps.ports.outputs.journal_port }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
envs: PR,PROJECT,DB,JOURNAL_PORT
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/trails-cool
|
||||
ENV_FILE="staging-pr-${PR}.env"
|
||||
|
||||
GHCR_TOKEN=$(grep DEPLOY_GHCR_TOKEN "$ENV_FILE" | cut -d= -f2-)
|
||||
echo "$GHCR_TOKEN" | docker login ghcr.io -u stigi --password-stdin
|
||||
|
||||
# Serialize all preview deploys with a server-side lock so concurrent
|
||||
# CI jobs (e.g. a Dependabot batch) can't race on eviction or compose state.
|
||||
exec 9>/tmp/trails-preview-deploy.lock
|
||||
flock --timeout 300 9 || { echo "Timed out waiting for deploy lock after 300s"; exit 1; }
|
||||
|
||||
# Same network bootstrap as deploy-staging — see comment there.
|
||||
docker network inspect trails-shared >/dev/null 2>&1 || docker network create trails-shared
|
||||
PG_CONTAINER=$(docker ps --filter "name=trails-cool-postgres" --format '{{.Names}}' | head -1)
|
||||
if [ -n "$PG_CONTAINER" ]; then
|
||||
docker network connect trails-shared "$PG_CONTAINER" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Concurrent preview limit (max 3): if we're at the cap and this
|
||||
# PR isn't already running, evict the oldest preview project.
|
||||
ACTIVE=$(docker compose ls --format json --filter "name=trails-pr-" | python3 -c 'import json,sys; data=json.load(sys.stdin); print("\n".join(d["Name"] for d in data))' 2>/dev/null || true)
|
||||
if [ -n "$ACTIVE" ] && ! echo "$ACTIVE" | grep -qx "$PROJECT"; then
|
||||
COUNT=$(echo "$ACTIVE" | grep -c '^trails-pr-' || true)
|
||||
if [ "$COUNT" -ge 3 ]; then
|
||||
# Pick the oldest by container CreatedAt of any service in the project.
|
||||
OLDEST=$(docker ps -a --filter "name=trails-pr-" --format '{{.Names}} {{.CreatedAt}}' \
|
||||
| awk '{ split($1,a,"-"); print "trails-pr-"a[3], $2" "$3" "$4 }' \
|
||||
| sort -k2 \
|
||||
| head -1 \
|
||||
| awk '{print $1}')
|
||||
if [ -n "$OLDEST" ] && [ "$OLDEST" != "$PROJECT" ]; then
|
||||
echo "At cap; evicting oldest preview: $OLDEST"
|
||||
OLD_PR=${OLDEST#trails-pr-}
|
||||
OLD_ENV="staging-pr-${OLD_PR}.env"
|
||||
# Fall back to base secrets if the per-PR env was already cleaned up.
|
||||
EVICT_ENV=$( [ -f "$OLD_ENV" ] && echo "$OLD_ENV" || echo "staging.env" )
|
||||
docker compose -f docker-compose.staging.yml -p "$OLDEST" --env-file "$EVICT_ENV" down --remove-orphans || true
|
||||
docker compose exec -T postgres dropdb -U trails --if-exists "trails_pr_$OLD_PR" || true
|
||||
rm -f "sites/pr-$OLD_PR.caddyfile" "$OLD_ENV"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure per-PR database exists with postgis. (See deploy-staging
|
||||
# for why we have to enable the extension explicitly.)
|
||||
docker compose exec -T postgres psql -U trails -d postgres -tAc \
|
||||
"SELECT 1 FROM pg_database WHERE datname='$DB'" \
|
||||
| grep -q 1 \
|
||||
|| docker compose exec -T postgres createdb -U trails "$DB"
|
||||
docker compose exec -T postgres psql -U trails -d "$DB" -c \
|
||||
"CREATE EXTENSION IF NOT EXISTS postgis"
|
||||
|
||||
# Pull, migrate, deploy (journal-only — no --profile means planner skipped)
|
||||
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" pull journal
|
||||
# Same drizzle-kit exit-code-0-on-error guard as the persistent
|
||||
# staging deploy above.
|
||||
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" run --rm journal npx drizzle-kit push --config /app/packages/db/drizzle.config.ts --force 2>&1 | tee /tmp/drizzle-push.log
|
||||
if grep -q "Error:" /tmp/drizzle-push.log; then
|
||||
echo "drizzle-kit push reported an error — failing the preview deploy"
|
||||
exit 1
|
||||
fi
|
||||
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" up -d --remove-orphans journal
|
||||
|
||||
# Reload Caddy to pick up the per-PR snippet (writes/replaces it from the SCP step)
|
||||
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile
|
||||
|
||||
# Same disk-hygiene prune as the persistent staging deploy —
|
||||
# preview pushes are the highest-volume image source.
|
||||
docker image prune -af --filter "until=1h" || true
|
||||
|
||||
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" ps
|
||||
|
||||
# Find any prior preview comment so we can update it in place rather
|
||||
# than spamming a new one each push. The marker line at the bottom of
|
||||
# the body is what `body-includes` matches on.
|
||||
- name: Find existing preview comment
|
||||
uses: peter-evans/find-comment@v4
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "<!-- cd-staging:preview -->"
|
||||
|
||||
- name: Upsert preview comment on PR
|
||||
uses: peter-evans/create-or-update-comment@v5
|
||||
with:
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
issue-number: ${{ github.event.number }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
🚀 **PR preview deployed**
|
||||
|
||||
- **Journal:** https://${{ steps.ports.outputs.host }}
|
||||
- **Planner (shared staging):** https://planner.staging.trails.cool
|
||||
- **Database:** `${{ steps.ports.outputs.database }}` (separate from production / persistent staging)
|
||||
- **Build:** [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) · commit `${{ github.event.pull_request.head.sha }}`
|
||||
|
||||
Updates automatically on push. Tears down when this PR closes.
|
||||
<!-- cd-staging:preview -->
|
||||
|
||||
|
||||
# ── PR preview teardown ──────────────────────────────────────────────
|
||||
teardown-preview:
|
||||
name: Tear Down PR Preview
|
||||
# Tear down on close, or when the `preview` opt-in is removed (label pulled
|
||||
# and no `<!-- preview -->` marker left) so a de-flagged PR doesn't orphan
|
||||
# its preview stack on the flagship.
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
(github.event.action == 'closed' ||
|
||||
(github.event.action == 'unlabeled' &&
|
||||
!(contains(github.event.pull_request.labels.*.name, 'preview') ||
|
||||
contains(github.event.pull_request.body, '<!-- preview -->'))))
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- id: ports
|
||||
name: Compute project + database name
|
||||
run: |
|
||||
PR=${{ github.event.number }}
|
||||
echo "pr=$PR" >> "$GITHUB_OUTPUT"
|
||||
echo "project=trails-pr-$PR" >> "$GITHUB_OUTPUT"
|
||||
echo "database=trails_pr_$PR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Decrypt secrets (needed to satisfy compose env vars during down)
|
||||
run: |
|
||||
curl -sLO https://github.com/getsops/sops/releases/download/v3.9.4/sops-v3.9.4.linux.amd64
|
||||
chmod +x sops-v3.9.4.linux.amd64
|
||||
SOPS_AGE_KEY="${{ secrets.AGE_SECRET_KEY }}" ./sops-v3.9.4.linux.amd64 -d infrastructure/secrets.app.env > infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
|
||||
{
|
||||
echo "DOMAIN=pr-${{ steps.ports.outputs.pr }}.staging.trails.cool"
|
||||
echo "STAGING_DATABASE=${{ steps.ports.outputs.database }}"
|
||||
echo "JOURNAL_HOST_PORT=$((3200 + 2 * ${{ steps.ports.outputs.pr }}))"
|
||||
echo "PLANNER_HOST_PORT=$((3201 + 2 * ${{ steps.ports.outputs.pr }}))"
|
||||
} >> infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env
|
||||
|
||||
- name: Copy compose + env (teardown still needs the file)
|
||||
uses: appleboy/scp-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
source: "infrastructure/docker-compose.staging.yml,infrastructure/staging-pr-${{ steps.ports.outputs.pr }}.env"
|
||||
target: /opt/trails-cool
|
||||
strip_components: 1
|
||||
|
||||
- name: Tear down via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
env:
|
||||
PR: ${{ steps.ports.outputs.pr }}
|
||||
PROJECT: ${{ steps.ports.outputs.project }}
|
||||
DB: ${{ steps.ports.outputs.database }}
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
envs: PR,PROJECT,DB
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/trails-cool
|
||||
ENV_FILE="staging-pr-${PR}.env"
|
||||
|
||||
# Stop and remove containers + volumes for this PR
|
||||
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file "$ENV_FILE" down --remove-orphans || true
|
||||
|
||||
# Drop the per-PR database (idempotent)
|
||||
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
|
||||
|
||||
# Remove the per-PR Caddy snippet, env file, and reload
|
||||
rm -f "sites/pr-$PR.caddyfile" "$ENV_FILE"
|
||||
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
|
||||
|
||||
- name: Find existing preview comment
|
||||
uses: peter-evans/find-comment@v4
|
||||
id: find-comment
|
||||
with:
|
||||
issue-number: ${{ github.event.number }}
|
||||
comment-author: "github-actions[bot]"
|
||||
body-includes: "<!-- cd-staging:preview -->"
|
||||
|
||||
- name: Update preview comment on close
|
||||
if: steps.find-comment.outputs.comment-id
|
||||
uses: peter-evans/create-or-update-comment@v5
|
||||
with:
|
||||
comment-id: ${{ steps.find-comment.outputs.comment-id }}
|
||||
edit-mode: replace
|
||||
body: |
|
||||
🧹 **PR preview torn down** (PR ${{ github.event.pull_request.merged && 'merged' || 'closed' }}).
|
||||
|
||||
Database `trails_pr_${{ steps.ports.outputs.pr }}` dropped, containers removed, Caddyfile snippet cleared.
|
||||
|
||||
[Teardown run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
<!-- cd-staging:preview -->
|
||||
|
||||
295
.github/workflows/ci.yml
vendored
295
.github/workflows/ci.yml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Gitleaks
|
||||
|
|
@ -42,27 +42,14 @@ jobs:
|
|||
name: Dockerfile Package Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- run: bash scripts/check-dockerfiles.sh
|
||||
|
||||
openspec:
|
||||
name: OpenSpec Validate
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm openspec validate --all --strict --no-interactive
|
||||
|
||||
typecheck:
|
||||
name: Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -75,7 +62,7 @@ jobs:
|
|||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -88,7 +75,7 @@ jobs:
|
|||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -101,7 +88,7 @@ jobs:
|
|||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -110,72 +97,6 @@ jobs:
|
|||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm build
|
||||
|
||||
visual-tests:
|
||||
name: Visual Tests
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Install Playwright deps only
|
||||
if: steps.playwright-cache.outputs.cache-hit == 'true'
|
||||
run: pnpm exec playwright install-deps chromium
|
||||
|
||||
- name: Run visual regression tests
|
||||
id: visual-tests
|
||||
run: pnpm --filter @trails-cool/planner test:visual
|
||||
|
||||
- name: Post diff comment on PR
|
||||
if: failure() && steps.visual-tests.outcome == 'failure' && github.event_name == 'pull_request'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
diffs=$(find apps/planner/.vitest-attachments -name "*-diff-*.png" 2>/dev/null | sort)
|
||||
if [ -z "$diffs" ]; then exit 0; fi
|
||||
|
||||
artifact_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
body="## Visual regression failures"$'\n\n'
|
||||
body+="The following tests produced screenshot diffs:"$'\n\n'
|
||||
for diff in $diffs; do
|
||||
name=$(basename "$diff" | sed 's/-diff-chromium-[a-z]*\.png//' | sed 's/-/ /g')
|
||||
body+="- \`$name\`"$'\n'
|
||||
done
|
||||
body+=$'\n'"**[Download the \`visual-snapshots-diff\` artifact]($artifact_url)** to inspect the diffs locally."$'\n\n'
|
||||
body+="To update snapshots if the change is intentional:"$'\n'
|
||||
body+="\`\`\`"$'\n'
|
||||
body+="pnpm --filter @trails-cool/planner test:visual:update"$'\n'
|
||||
body+="\`\`\`"
|
||||
|
||||
gh pr comment ${{ github.event.pull_request.number }} --body "$body"
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: visual-snapshots-diff
|
||||
path: apps/planner/.vitest-attachments/
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
needs: build
|
||||
|
|
@ -183,7 +104,7 @@ jobs:
|
|||
env:
|
||||
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
|
@ -191,12 +112,64 @@ jobs:
|
|||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Cache PostGIS Docker image
|
||||
id: postgis-cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/postgis-image.tar
|
||||
key: postgis-16-3.4
|
||||
|
||||
- name: Load or pull PostGIS image
|
||||
run: |
|
||||
if [ -f /tmp/postgis-image.tar ]; then
|
||||
docker load < /tmp/postgis-image.tar
|
||||
else
|
||||
docker pull postgis/postgis:16-3.4
|
||||
docker save postgis/postgis:16-3.4 > /tmp/postgis-image.tar
|
||||
fi
|
||||
|
||||
- name: Start PostgreSQL
|
||||
run: |
|
||||
docker run -d --name postgres \
|
||||
-e POSTGRES_USER=trails \
|
||||
-e POSTGRES_PASSWORD=trails \
|
||||
-e POSTGRES_DB=trails \
|
||||
-p 5432:5432 \
|
||||
postgis/postgis:16-3.4
|
||||
# Wait for pg_isready
|
||||
for i in $(seq 1 30); do
|
||||
docker exec postgres pg_isready -U trails > /dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
# Wait for PostGIS extension to be ready
|
||||
for i in $(seq 1 10); do
|
||||
docker exec postgres psql -U trails -c "SELECT PostGIS_Version();" > /dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Push database schema
|
||||
run: pnpm db:push
|
||||
|
||||
- name: Build and cache BRouter
|
||||
id: brouter-cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/brouter
|
||||
key: brouter-1.7.8
|
||||
|
||||
- name: Download BRouter
|
||||
if: steps.brouter-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p /tmp/brouter
|
||||
wget -q "https://github.com/abrensch/brouter/releases/download/v1.7.8/brouter-1.7.8.zip" -O /tmp/brouter/brouter.zip
|
||||
cd /tmp/brouter && unzip -o brouter.zip && mv brouter-1.7.8/* . && rmdir brouter-1.7.8 && rm brouter.zip
|
||||
|
||||
- name: Cache BRouter segment
|
||||
id: segment-cache
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: /tmp/brouter-segments
|
||||
key: brouter-segment-E10_N50-v1.7.9
|
||||
key: brouter-segment-E10_N50
|
||||
|
||||
- name: Download Berlin segment
|
||||
if: steps.segment-cache.outputs.cache-hit != 'true'
|
||||
|
|
@ -204,54 +177,26 @@ jobs:
|
|||
mkdir -p /tmp/brouter-segments
|
||||
wget -q "https://brouter.de/brouter/segments4/E10_N50.rd5" -O /tmp/brouter-segments/E10_N50.rd5
|
||||
|
||||
- name: Pre-seed BRouter segment volume
|
||||
- name: Start BRouter
|
||||
run: |
|
||||
docker volume create trails_brouter_segments
|
||||
docker run --rm \
|
||||
-v /tmp/brouter-segments:/src:ro \
|
||||
-v trails_brouter_segments:/dst \
|
||||
alpine sh -c "cp /src/*.rd5 /dst/ && chmod a+r /dst/*.rd5"
|
||||
|
||||
- name: Start services
|
||||
run: docker compose -f docker-compose.dev.yml up -d --wait --build
|
||||
cd /tmp/brouter
|
||||
java -Xmx256M -Xms64M \
|
||||
-DmaxRunningTime=300 \
|
||||
-cp brouter-1.7.8-all.jar \
|
||||
btools.server.RouteServer \
|
||||
/tmp/brouter-segments profiles2 profiles2 \
|
||||
17777 2 &
|
||||
# Wait for BRouter to start
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf http://localhost:17777/brouter?lonlats=13.4,52.5\|13.5,52.5\&profile=trekking\&format=geojson > /dev/null 2>&1 && break
|
||||
sleep 2
|
||||
done
|
||||
env:
|
||||
BROUTER_URL: http://localhost:17777
|
||||
|
||||
- name: Wait for BRouter routing
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
curl -s 'http://localhost:17777/brouter?lonlats=13.4,52.5|13.5,52.5&profile=trekking&format=geojson' 2>/dev/null | grep -q "FeatureCollection" && echo "BRouter ready" && break
|
||||
[ "$i" = "60" ] && echo "BRouter not ready after 120s" && exit 1
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Push database schema
|
||||
run: pnpm db:push
|
||||
|
||||
- name: Seed database
|
||||
run: pnpm db:seed
|
||||
|
||||
- name: Run integration tests
|
||||
# These talk to real Postgres. The unit-test job has no DB so
|
||||
# the `*.integration.test.ts` files skip there; this job has
|
||||
# the DB up + schema pushed, so flip the gate env vars to "1"
|
||||
# and let them run. Each gate is read by one file — see
|
||||
# `runIntegration` in each test.
|
||||
#
|
||||
# --no-file-parallelism: integration tests share the journal
|
||||
# schema and clean up by `DELETE FROM ... WHERE email LIKE
|
||||
# '%@example.test'`. Parallel files step on each other's rows
|
||||
# and trip FK constraints. Running sequentially is still <3s.
|
||||
run: pnpm --filter @trails-cool/journal exec vitest run --no-file-parallelism --reporter=default app/lib/explore.integration.test.ts app/lib/follow.integration.test.ts app/lib/demo-bot.integration.test.ts app/lib/notifications.integration.test.ts app/jobs/notifications-fanout.integration.test.ts
|
||||
env:
|
||||
EXPLORE_INTEGRATION: "1"
|
||||
FOLLOW_INTEGRATION: "1"
|
||||
DEMO_BOT_INTEGRATION: "1"
|
||||
NOTIFICATIONS_INTEGRATION: "1"
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
|
@ -273,12 +218,6 @@ jobs:
|
|||
run: pnpm test:e2e
|
||||
env:
|
||||
BROUTER_URL: http://localhost:17777
|
||||
# E2E=true is the explicit opt-out from the fail-loud
|
||||
# requireSecret() / getDatabaseUrl() guards — playwright boots
|
||||
# the server via `react-router serve` (NODE_ENV=production) but
|
||||
# against the local dev Postgres + local cookie secrets.
|
||||
E2E: "true"
|
||||
INTEGRATION_SECRET: ${{ secrets.INTEGRATION_SECRET }}
|
||||
|
||||
- name: Playwright job summary
|
||||
if: ${{ !cancelled() }}
|
||||
|
|
@ -316,87 +255,3 @@ jobs:
|
|||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
journal-image-smoke:
|
||||
# Build the journal's *production* Docker image (the `runtime` stage)
|
||||
# and actually boot it. Nothing else in CI does this: typecheck /
|
||||
# lint / test / build all run against the source tree, and the e2e
|
||||
# job boots the journal via `react-router-serve`, not the production
|
||||
# `node server.ts` entrypoint. The runtime stage copies source files
|
||||
# in by name (server.ts, app/lib, serve-static.ts, ...), so a refactor
|
||||
# that adds a file `server.ts` imports — without a matching COPY —
|
||||
# builds green everywhere and only crash-loops once deployed
|
||||
# (ERR_MODULE_NOT_FOUND). That has taken prod down more than once
|
||||
# (app/lib, app/jobs, serve-static.ts). Booting the real image and
|
||||
# hitting /api/health closes that gap: a missing static OR dynamic
|
||||
# import never reaches a healthy 200.
|
||||
name: Journal Image Smoke Test
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: imresamu/postgis:16-3.4
|
||||
env:
|
||||
POSTGRES_USER: trails
|
||||
POSTGRES_PASSWORD: trails
|
||||
POSTGRES_DB: trails
|
||||
ports:
|
||||
- 5432:5432
|
||||
# The postgis image restarts mid-init while it creates the
|
||||
# extension; the health check only passes once the final server
|
||||
# is up, so dependents don't race the init restart.
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U trails"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 20
|
||||
env:
|
||||
DATABASE_URL: postgres://trails:trails@localhost:5432/trails
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
|
||||
# seedOAuthClient + the demo/notifications job worker run on boot
|
||||
# and write to real tables, so the image needs a schema to come up
|
||||
# healthy. The postgis extension is auto-created by the image.
|
||||
- name: Push database schema
|
||||
run: pnpm db:push
|
||||
|
||||
- name: Build journal runtime image
|
||||
run: docker build --target runtime -f apps/journal/Dockerfile -t journal-smoke .
|
||||
|
||||
- name: Boot image and wait for healthy
|
||||
run: |
|
||||
# --network host: reach the service Postgres at localhost:5432
|
||||
# and publish the server on localhost:3000 in one shot.
|
||||
# E2E=true is the documented opt-out from the fail-loud
|
||||
# getDatabaseUrl() prod guard (CI points at a local Postgres).
|
||||
docker run -d --name journal-smoke --network host \
|
||||
-e NODE_ENV=production -e E2E=true \
|
||||
-e DATABASE_URL="$DATABASE_URL" \
|
||||
journal-smoke
|
||||
code=000
|
||||
for i in $(seq 1 30); do
|
||||
code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://localhost:3000/api/health || echo 000)
|
||||
echo "attempt $i: /api/health -> $code"
|
||||
[ "$code" = "200" ] && break
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' journal-smoke 2>/dev/null)" != "true" ]; then
|
||||
echo "::error::journal container exited during boot"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::journal production image failed to boot healthy (see logs below)"
|
||||
docker logs journal-smoke 2>&1 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "journal production image booted healthy"
|
||||
|
||||
- name: Container logs
|
||||
if: always()
|
||||
run: docker logs journal-smoke 2>&1 | tail -40 || true
|
||||
|
|
|
|||
94
.github/workflows/dependabot-auto-fix.yml
vendored
94
.github/workflows/dependabot-auto-fix.yml
vendored
|
|
@ -1,94 +0,0 @@
|
|||
name: Dependabot auto-fix
|
||||
|
||||
# Post-processing that runs on every dependabot PR and pushes the result
|
||||
# back to the PR branch. Two fixups, in one workflow so there is a single
|
||||
# checkout / commit / push (two workflows racing to push the same branch
|
||||
# would collide on a non-fast-forward):
|
||||
#
|
||||
# 1. pnpm dedupe — `pnpm install` alone doesn't dedupe peer copies of a
|
||||
# package (e.g. two versions of i18next, each holding their own
|
||||
# singleton state). That split caused a hydration mismatch on #272
|
||||
# until a manual `pnpm dedupe` collapsed them.
|
||||
#
|
||||
# 2. openspec update — the OpenSpec agent skills (.agents/skills/openspec-*)
|
||||
# and opsx slash commands (.claude/commands/opsx/*) are generated files
|
||||
# stamped with the CLI version that produced them. When dependabot bumps
|
||||
# @fission-ai/openspec they go stale until regenerated (see PR #567,
|
||||
# which did the 1.2.0 -> 1.6.0 regen by hand). `openspec update --force`
|
||||
# rewrites them to match the freshly-installed CLI.
|
||||
#
|
||||
# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a
|
||||
# fine-grained PAT (or GitHub App token) with `contents: write` on
|
||||
# this repo. The default `GITHUB_TOKEN` would work for the push but
|
||||
# would NOT trigger a subsequent CI run on that push (GitHub's
|
||||
# anti-loop safeguard), leaving the PR with stale green CI from
|
||||
# before the fixup commit. A PAT re-triggers CI so the reviewer
|
||||
# sees test results for the state they'd actually be merging.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require DEPENDABOT_DEDUPE_TOKEN
|
||||
env:
|
||||
TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow"
|
||||
echo "::error::requires a PAT so the fixup commit re-triggers CI."
|
||||
echo "::error::See .github/workflows/dependabot-auto-fix.yml header."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
# With `persist-credentials: true`, this PAT is stashed by the
|
||||
# action (under $RUNNER_TEMP in recent versions) and made
|
||||
# available to subsequent git operations in this workspace —
|
||||
# so the later `git push` authenticates as the PAT, which is
|
||||
# what gets CI to re-trigger on the fixup commit. `true` is
|
||||
# the checkout default; pinned explicitly here because it's
|
||||
# load-bearing for this workflow.
|
||||
token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
|
||||
persist-credentials: true
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile=false
|
||||
- name: Dedupe lockfile
|
||||
run: pnpm dedupe
|
||||
- name: Regenerate OpenSpec tool files
|
||||
# --force so the generated files always match the installed CLI,
|
||||
# even if `update` would otherwise consider them up to date. No-op
|
||||
# (no diff) when @fission-ai/openspec wasn't bumped in this PR.
|
||||
run: pnpm exec openspec update --force
|
||||
- name: Commit fixups
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
|
||||
run: |
|
||||
git add pnpm-lock.yaml .agents/skills/openspec-* .claude/commands/opsx
|
||||
if git diff --cached --quiet; then
|
||||
echo "Nothing to fix up — lockfile deduped and OpenSpec files current."
|
||||
exit 0
|
||||
fi
|
||||
# Build a message naming only the parts that actually changed.
|
||||
parts=""
|
||||
git diff --cached --name-only | grep -q '^pnpm-lock.yaml$' && parts="pnpm dedupe"
|
||||
if git diff --cached --name-only | grep -qE '^(\.agents/skills/openspec-|\.claude/commands/opsx)'; then
|
||||
parts="${parts:+$parts + }openspec update"
|
||||
fi
|
||||
git config user.name "dependabot[bot]"
|
||||
git config user.email "49699333+dependabot[bot]@users.noreply.github.com"
|
||||
git commit -m "[github-actions] $parts"
|
||||
git push
|
||||
echo "Pushed fixups: $parts"
|
||||
74
.github/workflows/dependabot-dedupe.yml
vendored
Normal file
74
.github/workflows/dependabot-dedupe.yml
vendored
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
name: Dependabot dedupe
|
||||
|
||||
# Dependabot opens a PR after a bump, but `pnpm install` alone doesn't
|
||||
# dedupe peer copies of packages (e.g. two versions of i18next, each
|
||||
# holding their own singleton state). That split caused a hydration
|
||||
# mismatch on #272 until a manual `pnpm dedupe` collapsed them.
|
||||
#
|
||||
# This workflow fires on every dependabot PR, runs `pnpm dedupe`, and
|
||||
# pushes the resulting lockfile update back to the PR branch so the
|
||||
# subsequent CI run tests the deduped tree.
|
||||
#
|
||||
# Requires `DEPENDABOT_DEDUPE_TOKEN` — a repo secret holding a
|
||||
# fine-grained PAT (or GitHub App token) with `contents: write` on
|
||||
# this repo. The default `GITHUB_TOKEN` would work for the push but
|
||||
# would NOT trigger a subsequent CI run on that push (GitHub's
|
||||
# anti-loop safeguard), leaving the PR with stale green CI from
|
||||
# before the dedupe commit. A PAT re-triggers CI so the reviewer
|
||||
# sees test results for the state they'd actually be merging.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
dedupe:
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Require DEPENDABOT_DEDUPE_TOKEN
|
||||
env:
|
||||
TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "::error::DEPENDABOT_DEDUPE_TOKEN is not set. This workflow"
|
||||
echo "::error::requires a PAT so the dedupe commit re-triggers CI."
|
||||
echo "::error::See .github/workflows/dependabot-dedupe.yml header."
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.head_ref }}
|
||||
# With `persist-credentials: true`, this PAT is stashed by the
|
||||
# action (under $RUNNER_TEMP in recent versions) and made
|
||||
# available to subsequent git operations in this workspace —
|
||||
# so the later `git push` authenticates as the PAT, which is
|
||||
# what gets CI to re-trigger on the dedupe commit. `true` is
|
||||
# the checkout default; pinned explicitly here because it's
|
||||
# load-bearing for this workflow.
|
||||
token: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
|
||||
persist-credentials: true
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile=false
|
||||
- run: pnpm dedupe
|
||||
- name: Commit dedupe changes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.DEPENDABOT_DEDUPE_TOKEN }}
|
||||
run: |
|
||||
if [ -n "$(git status --porcelain pnpm-lock.yaml)" ]; then
|
||||
git config user.name "dependabot[bot]"
|
||||
git config user.email "49699333+dependabot[bot]@users.noreply.github.com"
|
||||
git add pnpm-lock.yaml
|
||||
git commit -m "[github-actions] pnpm dedupe"
|
||||
git push
|
||||
echo "Deduped lockfile pushed."
|
||||
else
|
||||
echo "Lockfile already deduped."
|
||||
fi
|
||||
56
.github/workflows/disk-maintenance.yml
vendored
56
.github/workflows/disk-maintenance.yml
vendored
|
|
@ -1,56 +0,0 @@
|
|||
name: Disk Maintenance
|
||||
|
||||
# Daily safety net for flagship disk usage. The deploy workflows prune
|
||||
# superseded image layers after their own runs, but that protection
|
||||
# disappears exactly when it's needed most: a deploy that fails early
|
||||
# never reaches its prune step, while other workflows keep pulling
|
||||
# fresh images (2026-06-07 incident: cd-apps red all day, ~10 staging
|
||||
# deploys, disk 100% full, postgres down on a Saturday morning).
|
||||
#
|
||||
# Also doubles as a redundant alert channel: the run FAILS when the
|
||||
# disk is still above the threshold after pruning, so a scheduled-run
|
||||
# failure email lands even if the Grafana disk alert drowns in other
|
||||
# noise (which is what happened during the incident).
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Daily at 04:30 UTC (offset from staging-cleanup's Monday 04:00)
|
||||
- cron: "30 4 * * *"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: disk-maintenance
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
prune-flagship:
|
||||
name: Prune unused images (flagship)
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- name: Prune and check disk headroom
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script: |
|
||||
set -euo pipefail
|
||||
echo "before: $(df -h / | tail -1)"
|
||||
# 12h filter: never touch layers a same-day deploy may still
|
||||
# be assembling; running containers' images are never pruned.
|
||||
# `|| true`: a concurrent deploy's prune can make this collide
|
||||
# with "a prune operation is already running" — that's benign
|
||||
# (the other prune is freeing space too), and the disk-% gate
|
||||
# below is the real assertion, so don't fail on the collision.
|
||||
docker image prune -af --filter "until=12h" || true
|
||||
echo "after: $(df -h / | tail -1)"
|
||||
|
||||
PCT=$(df --output=pcent / | tail -1 | tr -dc '0-9')
|
||||
if [ "$PCT" -ge 85 ]; then
|
||||
echo "Disk still at ${PCT}% after pruning — needs a human."
|
||||
echo "Largest docker consumers:"
|
||||
docker system df
|
||||
exit 1
|
||||
fi
|
||||
echo "Disk at ${PCT}% — healthy."
|
||||
112
.github/workflows/staging-cleanup.yml
vendored
112
.github/workflows/staging-cleanup.yml
vendored
|
|
@ -1,112 +0,0 @@
|
|||
name: Staging Cleanup
|
||||
|
||||
# Sweeps the production server for orphaned PR-preview resources whose PRs
|
||||
# have closed without the cd-staging teardown job running (e.g., the
|
||||
# teardown failed, the workflow file was changed mid-flight, or the PR was
|
||||
# closed while runners were down). Runs weekly and can be triggered ad-hoc.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every Monday at 04:00 UTC
|
||||
- cron: "0 4 * * 1"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: staging-cleanup
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Sweep orphaned previews
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: List active preview projects
|
||||
id: list
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
script_stop: true
|
||||
script: |
|
||||
cd /opt/trails-cool
|
||||
# Emit one project name per line, e.g. "trails-pr-123"
|
||||
docker compose ls --format json --filter "name=trails-pr-" \
|
||||
| python3 -c 'import json,sys
|
||||
try:
|
||||
data = json.load(sys.stdin)
|
||||
except Exception:
|
||||
data = []
|
||||
for d in data:
|
||||
n = d.get("Name","")
|
||||
if n.startswith("trails-pr-"):
|
||||
print(n)' \
|
||||
|| true
|
||||
|
||||
- name: Determine which PRs are still open
|
||||
id: orphans
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PROJECTS: ${{ steps.list.outputs.stdout }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ORPHANS=()
|
||||
if [ -z "${PROJECTS:-}" ]; then
|
||||
echo "No active preview projects."
|
||||
echo "orphans=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
while IFS= read -r project; do
|
||||
[ -z "$project" ] && continue
|
||||
pr="${project#trails-pr-}"
|
||||
# If gh can't find the PR (deleted) or it's not OPEN, treat as orphan.
|
||||
state=$(gh pr view "$pr" --repo "${{ github.repository }}" --json state -q .state 2>/dev/null || echo "MISSING")
|
||||
if [ "$state" != "OPEN" ]; then
|
||||
echo "Orphan: PR #$pr (state=$state) → tear down $project"
|
||||
ORPHANS+=("$pr")
|
||||
fi
|
||||
done <<< "${PROJECTS}"
|
||||
IFS=,
|
||||
echo "orphans=${ORPHANS[*]:-}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Tear down orphans
|
||||
if: steps.orphans.outputs.orphans != ''
|
||||
env:
|
||||
ORPHANS: ${{ steps.orphans.outputs.orphans }}
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
username: root
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
envs: ORPHANS
|
||||
script: |
|
||||
set -euo pipefail
|
||||
cd /opt/trails-cool
|
||||
IFS=, read -ra PRS <<< "$ORPHANS"
|
||||
for PR in "${PRS[@]}"; do
|
||||
[ -z "$PR" ] && continue
|
||||
PROJECT="trails-pr-$PR"
|
||||
DB="trails_pr_$PR"
|
||||
echo "→ tearing down $PROJECT"
|
||||
# `down` needs the same env file the deploy used; staging.env on
|
||||
# disk may belong to a different PR, so synthesize a minimal one.
|
||||
cat > /tmp/cleanup.env <<EOF
|
||||
DOMAIN=pr-$PR.staging.trails.cool
|
||||
STAGING_DATABASE=$DB
|
||||
JOURNAL_HOST_PORT=$((3200 + 2 * PR))
|
||||
PLANNER_HOST_PORT=$((3201 + 2 * PR))
|
||||
JWT_SECRET=cleanup
|
||||
SESSION_SECRET=cleanup
|
||||
BROUTER_URL=http://placeholder
|
||||
BROUTER_AUTH_TOKEN=placeholder
|
||||
EOF
|
||||
docker compose -f docker-compose.staging.yml -p "$PROJECT" --env-file /tmp/cleanup.env down --remove-orphans || true
|
||||
docker compose exec -T postgres dropdb -U trails --if-exists "$DB" || true
|
||||
rm -f "sites/pr-$PR.caddyfile" "staging-pr-${PR}.env"
|
||||
done
|
||||
rm -f /tmp/cleanup.env
|
||||
docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || true
|
||||
103
.github/workflows/update-visual-snapshots.yml
vendored
103
.github/workflows/update-visual-snapshots.yml
vendored
|
|
@ -1,103 +0,0 @@
|
|||
name: Update visual snapshots
|
||||
|
||||
# How to use this workflow
|
||||
# ========================
|
||||
#
|
||||
# Visual snapshots live in `apps/planner/app/**/__screenshots__/` and are
|
||||
# committed to the repo. They are generated by Vitest browser mode running
|
||||
# the Planner's `*.browser.test.tsx` files against real Chromium via Playwright.
|
||||
#
|
||||
# When to update snapshots:
|
||||
# - You intentionally changed the look of the elevation chart (new color mode,
|
||||
# layout change, etc.) and the old snapshots are now wrong.
|
||||
# - You added a new `*.browser.test.tsx` test and need the initial snapshots.
|
||||
#
|
||||
# Two ways to trigger this workflow:
|
||||
#
|
||||
# 1. Manual dispatch (workflow_dispatch):
|
||||
# Go to Actions → "Update visual snapshots" → "Run workflow".
|
||||
# Choose the branch you want updated. The workflow will commit the new
|
||||
# snapshots back to that branch.
|
||||
#
|
||||
# 2. PR label (update-snapshots):
|
||||
# Add the `update-snapshots` label to any PR. The workflow will update
|
||||
# snapshots on the PR's head branch. Remove the label after to avoid
|
||||
# re-triggering on every subsequent push.
|
||||
#
|
||||
# After the workflow commits updated snapshots, pull the branch locally:
|
||||
# git pull origin <your-branch>
|
||||
#
|
||||
# Running locally:
|
||||
# pnpm --filter @trails-cool/planner test:visual # run tests
|
||||
# pnpm --filter @trails-cool/planner test:visual:update # update snapshots
|
||||
#
|
||||
# Platform note:
|
||||
# Snapshots are generated on ubuntu-latest to keep CI and local results
|
||||
# consistent. Snapshots generated on macOS or Windows will have subtle
|
||||
# font-rendering differences and will fail on CI. Always use this workflow
|
||||
# (or a Linux machine / Docker) to produce the canonical snapshots.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to update snapshots on"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
update-snapshots:
|
||||
# Only run for manual dispatch, or when the label is "update-snapshots"
|
||||
if: >
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'pull_request' && github.event.label.name == 'update-snapshots')
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
contents: write # needed to push snapshot commits back
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref || github.event.inputs.branch || github.ref }}
|
||||
# Use a token with push rights so the commit-back step can push
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: pnpm exec playwright install chromium --with-deps
|
||||
|
||||
- name: Update visual snapshots
|
||||
run: pnpm --filter @trails-cool/planner test:visual:update
|
||||
|
||||
- name: Commit updated snapshots
|
||||
uses: stefanzweifel/git-auto-commit-action@v7
|
||||
with:
|
||||
commit_message: "chore: update visual snapshots [skip ci]"
|
||||
file_pattern: "apps/planner/app/**/__screenshots__/**"
|
||||
commit_user_name: "github-actions[bot]"
|
||||
commit_user_email: "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Upload snapshots as artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: visual-snapshots
|
||||
path: apps/planner/app/**/__screenshots__/
|
||||
if-no-files-found: ignore
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -10,10 +10,8 @@ dist/
|
|||
.crit.json
|
||||
e2e/results/
|
||||
test-results/
|
||||
.env.development
|
||||
playwright-report/
|
||||
playwright-results.json
|
||||
.claude/worktrees/
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
docs/reviews/internal/
|
||||
|
|
|
|||
101
CLAUDE.md
101
CLAUDE.md
|
|
@ -9,9 +9,7 @@ trails.cool is a federated, self-hostable platform for outdoor enthusiasts with
|
|||
|
||||
Full architecture: `docs/architecture.md`
|
||||
Philosophy: `docs/philosophy.md`
|
||||
Roadmap: `docs/roadmap.md`
|
||||
Ideas (pre-spec explorations): `docs/ideas/`
|
||||
OpenSpec changes: `openspec/changes/`
|
||||
OpenSpec change: `openspec/changes/phase-1-mvp/`
|
||||
|
||||
## Principles
|
||||
|
||||
|
|
@ -27,7 +25,7 @@ OpenSpec changes: `openspec/changes/`
|
|||
- **Frontend**: React + Tailwind CSS + React Router 7 (Remix stack)
|
||||
- **Maps**: Leaflet + OpenStreetMap tiles
|
||||
- **CRDT**: Yjs + y-websocket (Planner only)
|
||||
- **Federation**: Fedify (Journal only)
|
||||
- **Federation**: Fedify (Journal only, Phase 2)
|
||||
- **Database**: PostgreSQL + PostGIS
|
||||
- **Media storage**: S3-compatible (Garage)
|
||||
- **Routing engine**: BRouter (Java, runs as separate Docker container)
|
||||
|
|
@ -41,15 +39,11 @@ apps/
|
|||
planner/ — Planner app (React Router 7)
|
||||
journal/ — Journal app (React Router 7 + Fedify)
|
||||
packages/
|
||||
types/ — Shared wire types both apps exchange (Waypoint)
|
||||
map-core/ — Framework-free map constants (colors, tiles, POI, z-index, snap); safe to import server-side
|
||||
types/ — Shared TypeScript interfaces (Route, Activity, Waypoint)
|
||||
ui/ — Shared React components (Tailwind)
|
||||
map/ — Leaflet map wrappers and tile layer configs
|
||||
gpx/ — GPX parsing, generation, validation
|
||||
fit/ — FIT file generation (Wahoo route push)
|
||||
i18n/ — react-i18next config + translations
|
||||
api/ — Shared API contracts (endpoints, pagination, error types, versioning)
|
||||
db/ — Drizzle schema, database client, migration helpers
|
||||
jobs/ — pg-boss setup, worker, and background job types
|
||||
sentry-config/ — Shared Sentry configuration
|
||||
infrastructure/ — Terraform + Docker Compose
|
||||
openspec/ — OpenSpec specs and changes
|
||||
docs/ — Architecture, philosophy, tooling docs
|
||||
|
|
@ -74,40 +68,6 @@ pnpm db:push # Push Drizzle schema to local PostgreSQL
|
|||
pnpm db:studio # Open Drizzle Studio (DB browser)
|
||||
```
|
||||
|
||||
### Local HTTPS dev (rare — most contributors never need this)
|
||||
|
||||
The default dev loop runs the journal on plain HTTP at
|
||||
`http://localhost:3000`. WebAuthn passkeys, magic links, sessions, the
|
||||
Terms gate, SSE — everything works over HTTP because the WebAuthn spec
|
||||
treats `localhost` as a secure context regardless of scheme. CI's e2e
|
||||
suite runs over plain HTTP too.
|
||||
|
||||
There is exactly one feature that requires local HTTPS: **Wahoo OAuth**.
|
||||
Wahoo (and most OAuth providers) reject `http://` redirect URIs, so the
|
||||
`/api/sync/connect/wahoo` callback flow can only complete against an
|
||||
HTTPS dev server. To run that flow:
|
||||
|
||||
```bash
|
||||
HTTPS=1 ORIGIN=https://localhost:3000 pnpm --filter @trails-cool/journal dev
|
||||
```
|
||||
|
||||
`HTTPS=1` enables the `@vitejs/plugin-basic-ssl` cert + the ALPN
|
||||
HTTP/1.1 workaround in `apps/journal/vite.config.ts`. `ORIGIN` makes the
|
||||
WebAuthn server expect the HTTPS origin (set this together with HTTPS=1
|
||||
or you'll get origin-mismatch errors). Use `pnpm --filter` (not
|
||||
`pnpm dev`) because turbo doesn't pass `HTTPS` through unless added to
|
||||
its `globalPassThroughEnv` — `pnpm --filter` bypasses turbo entirely.
|
||||
|
||||
**Don't set `ORIGIN=https://localhost:3000` in your `apps/journal/.env`
|
||||
unless you intend to always run with `HTTPS=1`.** Mismatched values
|
||||
break the e2e suite and generic dev. See
|
||||
`apps/journal/.env.example` for what each var means.
|
||||
|
||||
If you find yourself wanting `HTTPS=1` for any reason other than Wahoo
|
||||
testing, write it down here so the assumption stays auditable —
|
||||
"everything but Wahoo works over HTTP locally" is what keeps CI and
|
||||
local config symmetric.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests** (Vitest + jsdom): For packages, components, utilities, and app logic.
|
||||
|
|
@ -123,8 +83,8 @@ local config symmetric.
|
|||
|
||||
- **Route registration**: Both apps use explicit `routes.ts` (not file-based routing). When adding a new route file, you **must** add it to `apps/*/app/routes.ts` or it won't be compiled into the build.
|
||||
- All user-facing strings must use i18n (`useTranslation()` hook, never hardcode strings)
|
||||
- Database row types are derived from the Drizzle schema (`@trails-cool/db`); API wire shapes are the Zod contracts in `@trails-cool/api`; only types both apps exchange (e.g. Waypoint) live in `@trails-cool/types`
|
||||
- Map constants (colors, tiles, POI categories, z-indexes) go in `@trails-cool/map-core`; React/Leaflet map components live in the app that uses them
|
||||
- Use `@trails-cool/types` for shared interfaces — don't duplicate type definitions
|
||||
- Map components go in `@trails-cool/map`, not in individual apps
|
||||
- GPX parsing/generation goes in `@trails-cool/gpx`
|
||||
- Database schemas: `planner.*` for Planner data, `journal.*` for Journal data
|
||||
- Route geometry must be stored as PostGIS LineString (extracted from GPX on save)
|
||||
|
|
@ -183,15 +143,13 @@ Admins can bypass the PR workflow when necessary (e.g., CI is broken and needs a
|
|||
|
||||
## Deployment
|
||||
|
||||
Five CD workflows triggered by path or event:
|
||||
Three separate CD workflows triggered by path:
|
||||
|
||||
| Workflow | Triggers on | Deploys | Target |
|
||||
|----------|-------------|---------|--------|
|
||||
| `cd-apps.yml` | `apps/`, `packages/`, `pnpm-lock.yaml` | journal, planner | flagship (`root@trails.cool`) |
|
||||
| `cd-infra.yml` | `infrastructure/` (except `brouter-host/**`) | caddy, postgres, prometheus, loki, grafana, exporters | flagship (`root@trails.cool`) |
|
||||
| `cd-brouter.yml` | `docker/brouter/`, `infrastructure/brouter-host/**` | brouter + caddy sidecar | dedicated (`trails@ullrich.is:2232`) |
|
||||
| `cd-staging.yml` | main push or PR open/sync/close on `apps/`, `packages/` | persistent staging + per-PR previews | flagship (alongside production) |
|
||||
| `staging-cleanup.yml` | weekly cron + manual | sweeps orphaned PR previews | flagship |
|
||||
|
||||
### Hosts
|
||||
|
||||
|
|
@ -223,49 +181,6 @@ ssh -i ~/.ssh/trails-brouter-deploy -p 2232 trails@ullrich.is
|
|||
### Grafana
|
||||
`https://grafana.internal.trails.cool` — GitHub OAuth (trails-cool org)
|
||||
|
||||
### Staging & Previews
|
||||
|
||||
A persistent staging stack and ephemeral PR previews share the flagship server with production.
|
||||
|
||||
| Surface | URL | Database | Triggered by |
|
||||
|---------|-----|----------|--------------|
|
||||
| Persistent staging journal | `https://staging.trails.cool` | `trails_staging` | push to `main` |
|
||||
| Persistent staging planner | `https://planner.staging.trails.cool` | `trails_staging` | push to `main` |
|
||||
| PR preview journal | `https://pr-<N>.staging.trails.cool` | `trails_pr_<N>` | PR open/sync |
|
||||
|
||||
PR previews are **journal-only** — their `PLANNER_URL` points at the persistent staging planner so we don't pay 256MB per preview for an extra planner. The persistent staging planner's CSP allows `connect-src wss://*.staging.trails.cool` so PR-preview journals can talk to it.
|
||||
|
||||
**Port scheme** (host-published, reverse-proxied by Caddy via `host.docker.internal`):
|
||||
- Persistent staging: journal `3110`, planner `3111` (3100 collides with Loki on the vSwitch interface)
|
||||
- PR `<N>` preview: journal `3200 + 2N`, planner `3201 + 2N` (planner unused for previews)
|
||||
|
||||
**Compose project namespacing** keeps each preview isolated:
|
||||
- Persistent staging: `-p trails-staging`
|
||||
- PR `<N>`: `-p trails-pr-<N>`
|
||||
|
||||
The shared file `infrastructure/docker-compose.staging.yml` covers both — env vars (`DOMAIN`, `STAGING_DATABASE`, `JOURNAL_HOST_PORT`, `JOURNAL_IMAGE_TAG`, …) parametrize per target. Persistent staging uses `--profile persistent` to also start the planner; PR previews omit the profile.
|
||||
|
||||
**Caddy routing.** Persistent staging has fixed site blocks in `infrastructure/Caddyfile`. Per-PR site blocks are written by `cd-staging.yml` to `/opt/trails-cool/sites/pr-<N>.caddyfile` (mounted into Caddy at `/etc/caddy/sites/`) and picked up via `import sites/*.caddyfile` on a Caddy reload. No on-demand TLS; standard automatic HTTPS issues a per-host cert.
|
||||
|
||||
**Database isolation.** Each preview gets its own database on the production Postgres instance, schema applied via `drizzle-kit push --force`. Created on PR open, dropped on close. The persistent staging DB is never touched by previews.
|
||||
|
||||
**Concurrent preview cap.** Max 3 concurrent PR previews. When a 4th opens, the deploy job evicts the oldest project before deploying.
|
||||
|
||||
**Cleanup.** `cd-staging.yml`'s teardown job runs on PR close. `staging-cleanup.yml` runs weekly to catch orphans whose teardown never ran.
|
||||
|
||||
**Debugging.** SSH to the flagship (`ssh -i ~/.ssh/trails-cool-deploy root@trails.cool`) and run `docker compose -f docker-compose.staging.yml -p trails-pr-<N> logs -f` to tail a preview. `docker compose ls --filter name=trails-pr-` shows everything currently up.
|
||||
|
||||
## Agent Skills & Config
|
||||
|
||||
Skills are stored in `.agents/skills/` — the cross-agent convention (works with pi, Codex, and Claude Code via symlink).
|
||||
|
||||
```
|
||||
.agents/skills/ ← canonical location
|
||||
.claude/skills ← symlink → ../.agents/skills
|
||||
```
|
||||
|
||||
Both `.agents/` and `.claude/` also carry agent-specific config (commands, plugins, settings). Check them into version control so all agents see the same setup.
|
||||
|
||||
## OpenSpec Workflow
|
||||
|
||||
Specs live in `openspec/`. Use these slash commands:
|
||||
|
|
|
|||
198
CONTEXT.md
198
CONTEXT.md
|
|
@ -1,198 +0,0 @@
|
|||
# trails.cool domain glossary
|
||||
|
||||
This file names the domain concepts used in the codebase. New terms get added
|
||||
here as decisions crystallize during architecture work; the goal is that one
|
||||
concept has one name everywhere — specs, code, conversations.
|
||||
|
||||
If you're naming a new module, a new column, or a new UI surface, look here
|
||||
first. If the term you need isn't here, propose it (don't invent a synonym).
|
||||
|
||||
---
|
||||
|
||||
## GPX Save
|
||||
|
||||
The atomic unit of persisting spatial data in the Journal. Any write of a GPX track — whether a new route, an updated route, a new activity, or a route derived from an activity — goes through a single path that validates, writes the row, and writes the PostGIS geometry in one transaction.
|
||||
|
||||
### gpx-save module
|
||||
`apps/journal/app/lib/gpx-save.server.ts`. The sole owner of GPX validation and geometry persistence. Imported by `routes.server.ts`, `activities.server.ts`, and `demo-bot.server.ts`. Nothing else calls `setGeomFromGpx` directly.
|
||||
|
||||
### GpxValidationError
|
||||
Typed error thrown by `validateGpx` when the GPX string cannot produce a valid LineString. Conditions: fewer than 2 track points, or coordinates outside valid ranges (lat −90..90, lon −180..180). Callers catch this to return a user-facing 400.
|
||||
|
||||
### validateGpx
|
||||
`(gpx: string) → Promise<ParsedGpx>`. Entry point of the gpx-save module. Parses the GPX string once and validates the result. Returns the `ParsedGpx` so callers can extract stats without re-parsing. Throws `GpxValidationError` on invalid input. Called at the start of `createRoute`, `updateRoute`, `createActivity`, and `createRouteFromActivity` — before any DB write.
|
||||
|
||||
### atomic GPX save
|
||||
The invariant: a route or activity row with a `gpx` column set **always** has a corresponding `geom` column set. Enforced by wrapping the row insert/update, the PostGIS geometry write, and the version snapshot in a single `db.transaction()`. A PostGIS failure rolls back the row write; partial state (row exists, geom NULL) is not possible through the normal save path.
|
||||
|
||||
---
|
||||
|
||||
## Connected Services
|
||||
|
||||
The user-facing surface for linking external accounts and devices to a Journal
|
||||
account. Spec: `openspec/specs/connected-services/`.
|
||||
|
||||
### ConnectedService
|
||||
A single linked external account or device, owned by one user. Stored in the
|
||||
`connected_services` table (renamed from `sync_connections`). At most one row
|
||||
per `(user_id, provider)`.
|
||||
|
||||
### provider
|
||||
String identifier for the external system: `wahoo`, `komoot`, `apple-health`,
|
||||
and future `coros`, `garmin`, `strava`. The provider determines the
|
||||
`credential_kind` and which capabilities (import / push / webhook) the
|
||||
connection has, via the provider's manifest.
|
||||
|
||||
### credential kind
|
||||
Discriminator on `connected_services` describing the credential shape stored
|
||||
in the `credentials` JSONB blob. Three kinds today:
|
||||
|
||||
- **oauth** — OAuth2 access token, refresh token, expiry. Wahoo, and the
|
||||
expected shape for Coros / Garmin / Strava.
|
||||
- **web-login** — email + encrypted password + session jar. Komoot. No
|
||||
official API; we authenticate against the provider's normal web login and
|
||||
reuse the resulting session cookies. Refresh = re-login. Web-login
|
||||
breakage (form changes, captchas, password rotation) surfaces at the
|
||||
import layer, not at the credential layer.
|
||||
- **device** — no remote credential. Apple Health (and future Health Connect
|
||||
on Android). Data arrives via authenticated mobile API uploads, not
|
||||
server-initiated pulls. The `credentials` blob is empty; the connection
|
||||
exists so the UI can show "Apple Health is paired."
|
||||
|
||||
Credential kind is determined by the provider via its manifest, but stored
|
||||
explicitly on the row so queries don't need to join the manifest.
|
||||
|
||||
### granted_scopes
|
||||
Column on `connected_services`, populated only for `credential_kind = oauth`,
|
||||
NULL otherwise. Lists the OAuth scopes the user actually granted (e.g.
|
||||
`routes_write`). Feature gates query this column directly; missing a scope
|
||||
triggers re-authorization.
|
||||
|
||||
### provider_user_id
|
||||
The external service's identifier for the user. Used to route incoming
|
||||
webhooks to the right local user. Nullable (Apple Health has none in the
|
||||
remote-id sense).
|
||||
|
||||
### CredentialAdapter
|
||||
Per-kind module that knows how to maintain credentials of that kind:
|
||||
|
||||
- `oauth.refresh(creds) → creds | NeedsRelink`
|
||||
- `web-login.relogin(creds) → creds | InvalidCredentials`
|
||||
- `device` — no-op
|
||||
|
||||
Adapters do not import data, push routes, or handle webhooks. They only own
|
||||
the credential lifecycle.
|
||||
|
||||
### ConnectedServiceManager
|
||||
The deep module callers see. Owns:
|
||||
|
||||
- `link(userId, provider, credentials)` / `unlink(serviceId)`
|
||||
- `withFreshCredentials(serviceId, fn)` — refreshes via the right
|
||||
`CredentialAdapter` if expired, calls `fn(creds)`, marks the connection
|
||||
`needs_relink` if refresh fails.
|
||||
- `markNeedsRelink(serviceId, reason)` — called by import / push / webhook
|
||||
layers when they observe a credential failure (e.g. Komoot web-login
|
||||
fails, Wahoo returns 401 after a successful refresh).
|
||||
|
||||
Per-provider importers / pushers / webhook handlers always go through
|
||||
`withFreshCredentials` — they never read the `credentials` JSONB directly.
|
||||
|
||||
### provider manifest
|
||||
Per-provider declaration co-located with the provider's code
|
||||
(`providers/wahoo/manifest.ts`, etc.). Declares:
|
||||
|
||||
- the provider's `credential_kind`
|
||||
- which capabilities the provider implements (import? push? webhook?)
|
||||
- references to the per-capability modules
|
||||
|
||||
A small `providers/registry.ts` imports each manifest. Adding a provider is
|
||||
one new directory plus one import line.
|
||||
|
||||
## Sync Capabilities
|
||||
|
||||
Three orthogonal capabilities a provider may implement. Each is its own seam
|
||||
when there are ≥2 adapters; today most are single-adapter and held to a
|
||||
named shape so the second adapter doesn't reshape the interface.
|
||||
|
||||
### Importer
|
||||
Pulls workouts / activities from the external service into the Journal.
|
||||
Wahoo (OAuth pull), Komoot (web-login pull), Apple Health (mobile-pushed) all
|
||||
implement this, with very different mechanics. Dedup via `sync_imports`.
|
||||
|
||||
### RoutePusher
|
||||
Pushes a Journal route out to the external service. One adapter today
|
||||
(Wahoo); the seam exists so Coros / Garmin / Strava push won't reshape it.
|
||||
|
||||
The public seam is `pushRoute(connectedService, route) → {remoteId, version}`.
|
||||
Provider-specific concerns — FIT Course conversion, the `route:<id>`
|
||||
`external_id` convention, the PUT→POST-on-404 fallback — are **handled
|
||||
internally by the adapter**, not exposed on the seam. Idempotency is tracked
|
||||
via `sync_pushes`.
|
||||
|
||||
### WebhookReceiver
|
||||
Handles inbound webhooks. Today: Wahoo workout-published. Routes incoming
|
||||
webhooks to the right user via `provider_user_id`. Unknown
|
||||
`provider_user_id` returns 200 and is silently dropped (no leak).
|
||||
|
||||
## Storage tables
|
||||
|
||||
- `connected_services` — user ↔ provider links (renamed from
|
||||
`sync_connections`).
|
||||
- `sync_imports` — dedup cache for imported workouts, keyed by
|
||||
`(user_id, provider, workout_id)`.
|
||||
- `sync_pushes` — push state per `(user_id, route_id, provider)` →
|
||||
`remote_id`, `last_pushed_version`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
User identity in the Journal. Two **authentication methods** are supported
|
||||
and are intentionally the entire surface (see ADR-0005): **passkey**
|
||||
(WebAuthn) as the preferred method and **magic-link + 6-digit code**
|
||||
(email) as a fallback for users without passkey support or for cross-device
|
||||
sign-in. There is no plan for social sign-in (Google/Apple/etc.) — passkeys
|
||||
already deliver the one-tap UX, and adding centralized identity providers
|
||||
would conflict with the privacy-first ethos and ActivityPub federation.
|
||||
|
||||
OAuth2/PKCE (the `mobile-app` flow) is **not** a third authentication
|
||||
method. It is a **session transport** for native clients: users still
|
||||
authenticate via passkey or magic-link in a WebView, then the mobile app
|
||||
exchanges the resulting authorization code for long-lived bearer tokens.
|
||||
The peer of OAuth2 transport is the cookie session, not passkey or
|
||||
magic-link.
|
||||
|
||||
### completeAuth
|
||||
The single chokepoint for the post-verify orchestration of every web
|
||||
auth flow. Lives at `apps/journal/app/lib/auth/completion.ts` (see
|
||||
ADR-0004). Called by every route handler that has just verified a
|
||||
user's identity (passkey login finish, magic-link code verify, magic
|
||||
link consumer). Does three things in order:
|
||||
|
||||
1. If `isNewRegistration`, records the accepted Terms version
|
||||
(`recordTermsAcceptance`).
|
||||
2. Creates the session cookie via `createSession`.
|
||||
3. Returns `redirect(returnTo ?? "/")` with the session `Set-Cookie`
|
||||
header attached.
|
||||
|
||||
Identity-method-specific work (WebAuthn ceremony verification, magic
|
||||
token consumption) stays in the per-method functions and runs *before*
|
||||
`completeAuth`. The chokepoint deliberately knows nothing about how
|
||||
identity was proved.
|
||||
|
||||
### Terms gate
|
||||
Cross-cutting middleware enforcing that `users.terms_version` matches
|
||||
the current `TERMS_VERSION` constant before any non-allow-listed
|
||||
authenticated request succeeds. Two enforcement points:
|
||||
|
||||
- **Web (cookie sessions)**: the root loader redirects stale-terms users
|
||||
to `/auth/accept-terms`. Allow-list: `/auth/accept-terms`,
|
||||
`/auth/logout`, `/legal/*`. `/oauth/authorize` is *not* on the
|
||||
allow-list, so OAuth code issuance is gated by this same redirect
|
||||
before mobile sees an authorization code.
|
||||
- **API (bearer tokens)**: `requireApiUser` returns
|
||||
`403 { code: "TERMS_OUTDATED", currentTermsVersion }` for stale-terms
|
||||
bearer-token traffic. (Added in `mobile-terms-gate`, 2026-05-08.)
|
||||
|
||||
`completeAuth` only **records** terms on registration; it does not
|
||||
enforce them. Enforcement remains middleware's job.
|
||||
198
FEDERATION.md
198
FEDERATION.md
|
|
@ -1,198 +0,0 @@
|
|||
# Federation protocol
|
||||
|
||||
trails.cool's Journal federates over [ActivityPub](https://www.w3.org/TR/activitypub/),
|
||||
implemented with [Fedify](https://fedify.dev). This document describes the
|
||||
wire protocol precisely enough for another implementation to interoperate
|
||||
deliberately — actor discovery, the object and activity types we emit and
|
||||
accept, addressing, signatures, deduplication, delivery retry, and
|
||||
moderation. Examples use `trails.example` for our instance and
|
||||
`remote.example` for a peer.
|
||||
|
||||
Federation is per-instance opt-in (`FEDERATION_ENABLED`). When it is off,
|
||||
every federation surface returns 404 — a disabled instance is
|
||||
indistinguishable from one without the feature. Only users with
|
||||
`profile_visibility = 'public'` federate; a private user's actor,
|
||||
WebFinger, inbox, and outbox all 404, so their existence never leaks.
|
||||
|
||||
## Actor discovery
|
||||
|
||||
### WebFinger
|
||||
|
||||
`GET /.well-known/webfinger?resource=acct:alice@trails.example` resolves a
|
||||
handle to an actor IRI:
|
||||
|
||||
```json
|
||||
{
|
||||
"subject": "acct:alice@trails.example",
|
||||
"links": [
|
||||
{ "rel": "self", "type": "application/activity+json", "href": "https://trails.example/users/alice" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Actor
|
||||
|
||||
`GET https://trails.example/users/alice` with `Accept: application/activity+json`
|
||||
returns a `Person`. The actor IRI and the human profile `url` are the same
|
||||
by design (browsers get HTML at that URL via content negotiation):
|
||||
|
||||
```json
|
||||
{
|
||||
"@context": ["https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1"],
|
||||
"id": "https://trails.example/users/alice",
|
||||
"type": "Person",
|
||||
"preferredUsername": "alice",
|
||||
"name": "Alice",
|
||||
"summary": "trail runner",
|
||||
"url": "https://trails.example/users/alice",
|
||||
"inbox": "https://trails.example/users/alice/inbox",
|
||||
"outbox": "https://trails.example/users/alice/outbox",
|
||||
"publicKey": {
|
||||
"id": "https://trails.example/users/alice#main-key",
|
||||
"owner": "https://trails.example/users/alice",
|
||||
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"
|
||||
},
|
||||
"assertionMethod": [ { "type": "Multikey", "…": "…" } ],
|
||||
"attachment": [
|
||||
{ "type": "PropertyValue", "name": "🥾 trails.cool", "value": "<a href=\"https://trails.example/users/alice\" rel=\"me\">trails.example/users/alice</a>" },
|
||||
{ "type": "PropertyValue", "name": "Instance", "value": "<a href=\"https://trails.example\">trails.example</a>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`publicKey` is the RSA key Mastodon reads for HTTP-Signature verification;
|
||||
`assertionMethod` carries the same keys as Multikeys for newer stacks.
|
||||
|
||||
### NodeInfo (software discovery)
|
||||
|
||||
`GET /.well-known/nodeinfo` links to `GET /nodeinfo/2.1`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.1",
|
||||
"software": { "name": "trails-cool", "version": "1.2.3", "homepage": "https://trails.cool/" },
|
||||
"protocols": ["activitypub"],
|
||||
"usage": { "users": {}, "localPosts": 0, "localComments": 0 }
|
||||
}
|
||||
```
|
||||
|
||||
`software.name` is the machine-readable "this is a trails instance" marker
|
||||
used by the trails-to-trails outbound check. Usage counts are deliberately
|
||||
zeroed — publishing per-instance counts is a privacy decision we have not
|
||||
made.
|
||||
|
||||
## Objects and activities
|
||||
|
||||
Activities correspond to a user's journal entries. The object model is
|
||||
deliberately Mastodon-compatible: a `Create(Note)` whose HTML `content`
|
||||
summarizes the activity and whose `url` links to the journal detail page.
|
||||
(A first-class `trails:Route` object type is planned with route-federation;
|
||||
today everything is a Note.)
|
||||
|
||||
### Note
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "https://trails.example/activities/01H…",
|
||||
"type": "Note",
|
||||
"attributedTo": "https://trails.example/users/alice",
|
||||
"content": "<p>Morning trail run — 12.4 km, 480 m up</p>",
|
||||
"url": "https://trails.example/activities/01H…",
|
||||
"published": "2026-07-13T07:12:00Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"]
|
||||
}
|
||||
```
|
||||
|
||||
The Note IRI (`/activities/<id>`) is dereferenceable and serves
|
||||
`application/activity+json` — Mastodon's search-fetch and strict re-fetch
|
||||
of pushed objects both rely on this.
|
||||
|
||||
### Create / Delete
|
||||
|
||||
A publish is a `Create` wrapping the Note; the activity id is the object IRI
|
||||
with a `#create` fragment. A retraction is a `Delete` wrapping a `Tombstone`
|
||||
at the same object IRI:
|
||||
|
||||
```json
|
||||
{ "id": "https://trails.example/activities/01H…#create", "type": "Create",
|
||||
"actor": "https://trails.example/users/alice",
|
||||
"object": { "…": "the Note above" },
|
||||
"published": "2026-07-13T07:12:00Z",
|
||||
"to": ["https://www.w3.org/ns/activitystreams#Public"] }
|
||||
```
|
||||
|
||||
Note that a `Delete` poisons the object URI on the remote forever (remotes
|
||||
tombstone it); re-publishing the same URI after a Delete is silently
|
||||
refused by strict remotes.
|
||||
|
||||
### Follow graph
|
||||
|
||||
The inbox is **narrow** — only follow-graph activities are processed;
|
||||
anything else is acknowledged (`202`) and dropped.
|
||||
|
||||
| Inbound | Effect |
|
||||
|---|---|
|
||||
| `Follow` (remote → local public actor) | auto-accepted; we push back an `Accept(Follow)` |
|
||||
| `Undo(Follow)` | removes the follow |
|
||||
| `Accept(Follow)` | settles our outgoing Follow; triggers the first outbox poll |
|
||||
| `Reject(Follow)` | drops our pending outgoing Follow |
|
||||
|
||||
## Addressing
|
||||
|
||||
Public activities are addressed to `https://www.w3.org/ns/activitystreams#Public`
|
||||
and **push-delivered** to each accepted remote follower's inbox (fan-out,
|
||||
one delivery per follower). We do not implement shared-inbox delivery.
|
||||
Remotes do not backfill history — only pushed or individually-fetched
|
||||
objects appear on a peer.
|
||||
|
||||
## Signatures
|
||||
|
||||
All inbound activities must carry a valid HTTP Signature; Fedify verifies it
|
||||
against the sending actor's `publicKey` (fetched and cached). Unsigned or
|
||||
badly-signed requests are rejected. Outbound deliveries are signed with the
|
||||
sending user's key. An actor changing keys requires the remote to re-fetch
|
||||
the actor document.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Delivery is at-least-once, so receivers must be idempotent. trails dedups
|
||||
inbound activities two ways:
|
||||
|
||||
- **`Create(Note)`** — idempotent via a unique constraint on the activity's
|
||||
origin IRI (`remote_origin_iri`); a redelivered Create is a no-op.
|
||||
- **Follow-graph activities** (`Follow` / `Undo` / `Accept` / `Reject`) — the
|
||||
activity IRI is recorded in `federation_processed_activities` on first
|
||||
receipt (insert-or-drop before any side effect); a redelivery is dropped
|
||||
and counted (`federation_inbox_dropped_total{reason="duplicate"}`).
|
||||
Records are retained ≥ 30 days, which comfortably exceeds the
|
||||
HTTP-Signature date-freshness window, then swept.
|
||||
|
||||
## Delivery retry
|
||||
|
||||
Delivery queueing and retry state are **durable** — they survive process
|
||||
restarts and deploys (backed by PostgreSQL via pg-boss; Fedify owns the
|
||||
retry policy). On a `5xx` or timeout, a delivery retries with exponential
|
||||
backoff, giving up after a bounded budget (~8 attempts spanning roughly a
|
||||
day) before a permanent failure is logged. Deliveries are paced to at most
|
||||
1 request/second per remote host. Metrics:
|
||||
`federation_delivery_total{outcome}` and `federation_queue_depth`.
|
||||
|
||||
## Moderation
|
||||
|
||||
An operator can block a federation instance by domain (exact-host match).
|
||||
A blocked instance is **inert in both directions**:
|
||||
|
||||
- its inbound activities are silently dropped (`202`, no error oracle) and
|
||||
counted (`federation_inbox_dropped_total{reason="blocked"}`);
|
||||
- it receives no deliveries (blocked recipients are filtered from fan-out);
|
||||
- we never fetch its actors or outboxes.
|
||||
|
||||
Blocking is effective immediately (checked per request / per job, no cache).
|
||||
The operator procedure (a SQL insert/delete against
|
||||
`journal.federation_blocked_instances`) is documented in the
|
||||
[deployment runbook](docs/deployment.md#blocking-an-instance).
|
||||
|
||||
---
|
||||
|
||||
*Kept current as federation capabilities change. Specs:
|
||||
`openspec/specs/social-federation` and `openspec/specs/federation-operations`.*
|
||||
|
|
@ -96,14 +96,6 @@ docker compose up -d
|
|||
See [docs/architecture.md](docs/architecture.md) for details on self-hosting
|
||||
configuration.
|
||||
|
||||
## Federation
|
||||
|
||||
The Journal federates over ActivityPub. The wire protocol — actor
|
||||
discovery, object/activity types with JSON examples, addressing,
|
||||
signatures, deduplication, delivery retry, and moderation — is documented
|
||||
in [FEDERATION.md](FEDERATION.md), which is precise enough for another
|
||||
implementation to interoperate against.
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **Privacy by design** — The Planner collects zero user data
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
# Journal local-dev environment.
|
||||
# Copy to `.env` (gitignored) and edit. Most contributors don't need
|
||||
# anything in this file — the journal app boots on plain HTTP at
|
||||
# http://localhost:3000 with sensible defaults.
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# DO NOT SET unless you know you need it
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# `ORIGIN` is what the WebAuthn ceremony uses as `expectedOrigin`.
|
||||
# When unset it defaults to `http://localhost:3000`, which is what
|
||||
# Playwright sends and what the browser sees in plain-HTTP dev.
|
||||
#
|
||||
# The ONLY reason to set this is if you're running the dev server
|
||||
# over HTTPS (see HTTPS=1 below) and want passkey registration to
|
||||
# succeed against the HTTPS origin. If you set it to `https://...`
|
||||
# without also running the server over HTTPS, you'll get
|
||||
# "Unexpected registration response origin" errors in dev and the
|
||||
# local e2e suite (which always hits HTTP) will fail registration.
|
||||
#
|
||||
# ORIGIN=https://localhost:3000
|
||||
|
||||
# Flagship marker. Renders the project marketing block on the
|
||||
# anonymous home and gates a few "this is the canonical instance"
|
||||
# behaviors. Self-hosted instances leave this unset; flagship CI
|
||||
# sets it to `true`. Either is fine for local dev — set it if you
|
||||
# want to see the full marketing layout, leave it unset if you
|
||||
# want the self-host home view.
|
||||
# IS_FLAGSHIP=true
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Wahoo OAuth (only needed when actively testing the Wahoo import)
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Wahoo's OAuth flow is the one feature that genuinely needs HTTPS
|
||||
# locally: the provider rejects `http://` redirect URIs. To exercise
|
||||
# the connect/disconnect/import flow end-to-end, you need:
|
||||
#
|
||||
# 1. Local HTTPS dev server: run `HTTPS=1 pnpm --filter
|
||||
# @trails-cool/journal dev` (the basic-ssl plugin in
|
||||
# vite.config.ts wires up the cert; see the comment there for
|
||||
# the ALPN workaround).
|
||||
# 2. ORIGIN=https://localhost:3000 (uncomment above).
|
||||
# 3. Wahoo client credentials below, and a redirect URI of
|
||||
# `https://localhost:3000/api/sync/callback/wahoo` registered
|
||||
# in your Wahoo developer dashboard.
|
||||
#
|
||||
# WAHOO_CLIENT_ID=
|
||||
# WAHOO_CLIENT_SECRET=
|
||||
# WAHOO_WEBHOOK_TOKEN=
|
||||
|
||||
# Garmin Connect Developer Program credentials (spec: garmin-import).
|
||||
# Requires an approved program application; without these the Garmin
|
||||
# provider is hidden on /settings/connections. The OAuth callback to
|
||||
# register with Garmin is `<origin>/api/sync/callback/garmin`, the
|
||||
# notification endpoint `<origin>/api/sync/webhook/garmin`.
|
||||
# GARMIN_CLIENT_ID=
|
||||
# GARMIN_CLIENT_SECRET=
|
||||
|
||||
# Integration test secret (only needed if running the integration
|
||||
# test suite that drives the API directly). Generate with
|
||||
# `openssl rand -hex 32`.
|
||||
# INTEGRATION_SECRET=
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:26-slim AS base
|
||||
FROM node:25-slim AS base
|
||||
# curl is used by the docker-compose healthcheck (node:25-slim is Debian
|
||||
# trixie-slim and ships neither curl nor wget by default).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
|
||||
|
|
@ -9,29 +9,24 @@ FROM base AS deps
|
|||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/journal/package.json apps/journal/
|
||||
COPY packages/types/package.json packages/types/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/map/package.json packages/map/
|
||||
COPY packages/gpx/package.json packages/gpx/
|
||||
COPY packages/i18n/package.json packages/i18n/
|
||||
COPY packages/sentry-config/package.json packages/sentry-config/
|
||||
COPY packages/api/package.json packages/api/
|
||||
COPY packages/map-core/package.json packages/map-core/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/db/package.json packages/db/
|
||||
COPY packages/jobs/package.json packages/jobs/
|
||||
COPY packages/fit/package.json packages/fit/
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS build
|
||||
ARG SENTRY_RELEASE
|
||||
# Client-side Sentry DSN baked into the bundle at build time. Empty (or
|
||||
# unset) produces a Sentry-free client. Public-by-design: the DSN
|
||||
# appears in the shipped client JS regardless.
|
||||
ARG VITE_SENTRY_DSN=""
|
||||
COPY --from=deps /app/ ./
|
||||
COPY . .
|
||||
RUN --mount=type=secret,id=SENTRY_AUTH_TOKEN \
|
||||
SENTRY_AUTH_TOKEN="$(cat /run/secrets/SENTRY_AUTH_TOKEN 2>/dev/null | tr -d '\n\r')" \
|
||||
SENTRY_RELEASE="$SENTRY_RELEASE" \
|
||||
VITE_SENTRY_DSN="$VITE_SENTRY_DSN" \
|
||||
pnpm --filter @trails-cool/journal build
|
||||
|
||||
FROM base AS runtime
|
||||
|
|
@ -40,7 +35,6 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||
COPY --from=deps /app/apps/journal/node_modules ./apps/journal/node_modules
|
||||
COPY --from=build /app/apps/journal/build ./apps/journal/build
|
||||
COPY --from=build /app/apps/journal/server.ts ./apps/journal/server.ts
|
||||
COPY --from=build /app/apps/journal/serve-static.ts ./apps/journal/serve-static.ts
|
||||
COPY --from=build /app/apps/journal/app/lib ./apps/journal/app/lib
|
||||
COPY --from=build /app/apps/journal/app/jobs ./apps/journal/app/jobs
|
||||
COPY --from=build /app/apps/journal/package.json ./apps/journal/package.json
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Form, Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
interface Props {
|
||||
user: { username: string; displayName: string | null };
|
||||
}
|
||||
|
||||
export function AccountDropdown({ user }: Props) {
|
||||
const { t } = useTranslation("journal");
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click-outside + Escape close. Mounted only while open to keep the
|
||||
// listener cost zero in the steady state.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onClick);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={user.displayName ?? user.username}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
<Avatar displayName={user.displayName} username={user.username} size="md" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-20 mt-2 w-56 origin-top-right rounded-md border border-gray-200 bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5"
|
||||
>
|
||||
<div className="border-b border-gray-100 px-3 py-2">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
{user.displayName ?? user.username}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">@{user.username}</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/users/${user.username}`}
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{t("nav.profile")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
role="menuitem"
|
||||
onClick={() => setOpen(false)}
|
||||
className="block px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
<Form method="post" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
className="block w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
</Form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// Initials-only avatar. We don't have an image-upload story yet (the
|
||||
// `users` table has no avatar URL column), so initials are the
|
||||
// implementation. When images land, this component is the single
|
||||
// place to add the image fallback.
|
||||
|
||||
interface Props {
|
||||
displayName: string | null;
|
||||
username: string;
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function initialsOf(displayName: string | null, username: string): string {
|
||||
const source = (displayName ?? username).trim();
|
||||
if (source.length === 0) return "?";
|
||||
// Two-letter initials: first letter of the first two whitespace-
|
||||
// separated words, falling back to the first two characters when
|
||||
// the source is a single token.
|
||||
const parts = source.split(/\s+/).filter(Boolean);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0]![0]! + parts[1]![0]!).toUpperCase();
|
||||
}
|
||||
return source.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
const SIZE_CLASS: Record<NonNullable<Props["size"]>, string> = {
|
||||
sm: "h-7 w-7 text-[11px]",
|
||||
md: "h-9 w-9 text-sm",
|
||||
lg: "h-12 w-12 text-base",
|
||||
};
|
||||
|
||||
export function Avatar({ displayName, username, size = "md", className = "" }: Props) {
|
||||
const initials = initialsOf(displayName, username);
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full bg-gray-200 font-semibold text-gray-700 ${SIZE_CLASS[size]} ${className}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,14 +3,10 @@ import { useLocale } from "./LocaleContext";
|
|||
/**
|
||||
* Renders a date formatted with the server-detected locale,
|
||||
* ensuring SSR and client output match (no hydration flicker).
|
||||
* Pass `withTime` for surfaces where the hour/minute matter
|
||||
* (e.g. notifications), keeping plain dates as the default.
|
||||
*/
|
||||
export function ClientDate({ iso, withTime = false }: { iso: string; withTime?: boolean }) {
|
||||
export function ClientDate({ iso }: { iso: string }) {
|
||||
const locale = useLocale();
|
||||
const d = new Date(iso);
|
||||
const text = withTime
|
||||
? d.toLocaleString(locale, { dateStyle: "short", timeStyle: "short" })
|
||||
: d.toLocaleDateString(locale);
|
||||
return <time dateTime={iso}>{text}</time>;
|
||||
return (
|
||||
<time dateTime={iso}>{new Date(iso).toLocaleDateString(locale)}</time>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ interface Entry {
|
|||
username: string;
|
||||
displayName: string | null;
|
||||
domain: string;
|
||||
/** Local path (`/users/x`) or, for federated entries, the remote profile URL. */
|
||||
profileUrl: string;
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -45,10 +42,9 @@ export function CollectionPage({ kind, user, entries, page, total }: Props) {
|
|||
) : (
|
||||
<ul className="mt-6 divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
||||
{entries.map((entry) => (
|
||||
<li key={`${entry.username}@${entry.domain}`} className="px-4 py-3">
|
||||
<li key={entry.username} className="px-4 py-3">
|
||||
<a
|
||||
href={entry.profileUrl}
|
||||
{...(entry.remote ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
||||
href={`/users/${entry.username}`}
|
||||
className="flex items-center justify-between hover:underline"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { render, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { ElevationProfile } from "./ElevationProfile.tsx";
|
||||
import type { ElevationSample } from "@trails-cool/gpx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const labels = { highest: "Highest", lowest: "Lowest" };
|
||||
|
||||
const series: ElevationSample[] = [
|
||||
{ d: 0, e: 100, lat: 0, lng: 0 },
|
||||
{ d: 500, e: 160, lat: 0, lng: 0.005 },
|
||||
{ d: 1000, e: 120, lat: 0, lng: 0.01 },
|
||||
];
|
||||
|
||||
describe("ElevationProfile", () => {
|
||||
it("renders nothing for a too-short series", () => {
|
||||
const { container } = render(
|
||||
<ElevationProfile series={[series[0]!]} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
|
||||
);
|
||||
expect(container.querySelector("svg")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the chart and highest/lowest summary", () => {
|
||||
const { container, getByText } = render(
|
||||
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={() => {}} labels={labels} />,
|
||||
);
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
// area + line paths
|
||||
expect(container.querySelectorAll("path").length).toBeGreaterThanOrEqual(2);
|
||||
expect(getByText("160 m")).toBeTruthy(); // highest
|
||||
expect(getByText("100 m")).toBeTruthy(); // lowest
|
||||
});
|
||||
|
||||
it("draws an active marker when activeIndex is set", () => {
|
||||
const { container } = render(
|
||||
<ElevationProfile series={series} activeIndex={1} onActive={() => {}} onSeek={() => {}} labels={labels} />,
|
||||
);
|
||||
expect(container.querySelector("circle")).not.toBeNull();
|
||||
expect(container.querySelector("line")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reports seek on pointer down", () => {
|
||||
const onSeek = vi.fn();
|
||||
const { container } = render(
|
||||
<ElevationProfile series={series} activeIndex={null} onActive={() => {}} onSeek={onSeek} labels={labels} />,
|
||||
);
|
||||
const svg = container.querySelector("svg")!;
|
||||
// jsdom getBoundingClientRect returns zeros; we only assert the handler fires.
|
||||
fireEvent.pointerDown(svg, { clientX: 10 });
|
||||
expect(onSeek).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import { useRef } from "react";
|
||||
import type { ElevationSample } from "@trails-cool/gpx";
|
||||
import { formatElevationM, formatDistanceKm } from "~/lib/stats";
|
||||
|
||||
const W = 1000;
|
||||
const H = 220;
|
||||
const PAD = { top: 16, right: 8, bottom: 22, left: 46 };
|
||||
const PLOT_W = W - PAD.left - PAD.right;
|
||||
const PLOT_H = H - PAD.top - PAD.bottom;
|
||||
|
||||
/**
|
||||
* Read-only elevation profile chart (SVG, responsive via viewBox). Distance on
|
||||
* x, elevation on y, with a gradient area fill. Reports the hovered sample
|
||||
* index via `onActive` (for the map marker) and the clicked index via `onSeek`
|
||||
* (centre the map), and draws a marker at `activeIndex` (set by the map when the
|
||||
* route line is hovered). Renders nothing for an empty/too-short series.
|
||||
*/
|
||||
export function ElevationProfile({
|
||||
series,
|
||||
activeIndex,
|
||||
onActive,
|
||||
onSeek,
|
||||
labels,
|
||||
className,
|
||||
}: {
|
||||
series: ElevationSample[];
|
||||
activeIndex: number | null;
|
||||
onActive: (index: number | null) => void;
|
||||
onSeek: (index: number) => void;
|
||||
labels: { highest: string; lowest: string };
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = useRef<SVGSVGElement>(null);
|
||||
if (series.length < 2) return null;
|
||||
|
||||
const maxD = series[series.length - 1]!.d || 1;
|
||||
let minE = Infinity;
|
||||
let maxE = -Infinity;
|
||||
for (const s of series) {
|
||||
if (s.e < minE) minE = s.e;
|
||||
if (s.e > maxE) maxE = s.e;
|
||||
}
|
||||
const eRange = Math.max(1, maxE - minE);
|
||||
const baseY = PAD.top + PLOT_H;
|
||||
|
||||
const x = (d: number) => PAD.left + (d / maxD) * PLOT_W;
|
||||
const y = (e: number) => PAD.top + (1 - (e - minE) / eRange) * PLOT_H;
|
||||
|
||||
const linePath = series
|
||||
.map((s, i) => `${i === 0 ? "M" : "L"}${x(s.d).toFixed(1)},${y(s.e).toFixed(1)}`)
|
||||
.join(" ");
|
||||
const areaPath = `${linePath} L${x(maxD).toFixed(1)},${baseY} L${PAD.left},${baseY} Z`;
|
||||
|
||||
const active = activeIndex != null ? series[activeIndex] : null;
|
||||
|
||||
function indexFromClientX(clientX: number): number {
|
||||
const rect = ref.current!.getBoundingClientRect();
|
||||
const px = ((clientX - rect.left) / rect.width) * W; // → SVG user units
|
||||
const d = ((px - PAD.left) / PLOT_W) * maxD;
|
||||
let best = 0;
|
||||
let bestDelta = Infinity;
|
||||
for (let i = 0; i < series.length; i++) {
|
||||
const delta = Math.abs(series[i]!.d - d);
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-1 flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
{labels.highest} <span className="font-semibold text-gray-900">{formatElevationM(maxE)}</span>
|
||||
{" · "}
|
||||
{labels.lowest} <span className="font-semibold text-gray-900">{formatElevationM(minE)}</span>
|
||||
</span>
|
||||
{active && (
|
||||
<span className="tabular-nums">
|
||||
{formatDistanceKm(active.d)} · {formatElevationM(active.e)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
ref={ref}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
preserveAspectRatio="none"
|
||||
className="h-40 w-full touch-none select-none"
|
||||
role="img"
|
||||
aria-label="Elevation profile"
|
||||
onPointerMove={(e) => onActive(indexFromClientX(e.clientX))}
|
||||
onPointerLeave={() => onActive(null)}
|
||||
onPointerDown={(e) => onSeek(indexFromClientX(e.clientX))}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="elev-fill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="#2563eb" stopOpacity="0.35" />
|
||||
<stop offset="100%" stopColor="#2563eb" stopOpacity="0.03" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaPath} fill="url(#elev-fill)" />
|
||||
<path d={linePath} fill="none" stroke="#2563eb" strokeWidth="2" vectorEffect="non-scaling-stroke" />
|
||||
{active && (
|
||||
<g>
|
||||
<line
|
||||
x1={x(active.d)}
|
||||
y1={PAD.top}
|
||||
x2={x(active.d)}
|
||||
y2={baseY}
|
||||
stroke="#9ca3af"
|
||||
strokeWidth="1"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
<circle cx={x(active.d)} cy={y(active.e)} r="4" fill="#2563eb" stroke="#fff" strokeWidth="1.5" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -8,36 +8,21 @@ interface FollowState {
|
|||
|
||||
interface Props {
|
||||
username: string;
|
||||
// Whether the followed profile is private/locked. Drives the "Request to
|
||||
// follow" label vs. plain "Follow" before any click happens.
|
||||
isPrivateTarget: boolean;
|
||||
initialState: FollowState | null;
|
||||
}
|
||||
|
||||
type Display = "follow" | "request" | "pending" | "unfollow";
|
||||
|
||||
function displayFor(state: FollowState | null, isPrivateTarget: boolean): Display {
|
||||
if (state?.following) return "unfollow";
|
||||
if (state?.pending) return "pending";
|
||||
return isPrivateTarget ? "request" : "follow";
|
||||
}
|
||||
|
||||
export function FollowButton({ username, isPrivateTarget, initialState }: Props) {
|
||||
export function FollowButton({ username, initialState }: Props) {
|
||||
const { t } = useTranslation("journal");
|
||||
const [state, setState] = useState<FollowState>(
|
||||
initialState ?? { following: false, pending: false },
|
||||
);
|
||||
const [isInFlight, startTransition] = useTransition();
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const display = displayFor(state, isPrivateTarget);
|
||||
|
||||
const onClick = () => {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
// For "pending" we treat the click as cancel-request: same /unfollow
|
||||
// endpoint deletes the row whether it's accepted or pending.
|
||||
const path = state.following || state.pending
|
||||
const path = state.following
|
||||
? `/api/users/${username}/unfollow`
|
||||
: `/api/users/${username}/follow`;
|
||||
try {
|
||||
|
|
@ -55,33 +40,21 @@ export function FollowButton({ username, isPrivateTarget, initialState }: Props)
|
|||
});
|
||||
};
|
||||
|
||||
const label = (() => {
|
||||
switch (display) {
|
||||
case "unfollow":
|
||||
return t("social.unfollow");
|
||||
case "pending":
|
||||
return t("social.pendingCancel");
|
||||
case "request":
|
||||
return t("social.requestToFollow");
|
||||
case "follow":
|
||||
default:
|
||||
return t("social.follow");
|
||||
}
|
||||
})();
|
||||
|
||||
const baseClass = display === "follow" || display === "request"
|
||||
? "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
: "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50";
|
||||
const label = state.following ? t("social.unfollow") : t("social.follow");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={isInFlight}
|
||||
className={baseClass}
|
||||
disabled={isPending}
|
||||
className={
|
||||
state.following
|
||||
? "rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
: "rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
}
|
||||
>
|
||||
{isInFlight ? "…" : label}
|
||||
{isPending ? "…" : label}
|
||||
</button>
|
||||
{error && <p className="text-xs text-red-600">{error}</p>}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Form, Link, useLocation } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Avatar } from "./Avatar";
|
||||
|
||||
interface Props {
|
||||
user: { username: string; displayName: string | null };
|
||||
}
|
||||
|
||||
// Mobile drawer for the navbar. Replaces the "everything in a row"
|
||||
// desktop layout with a hamburger trigger and a slide-out panel that
|
||||
// holds all the same destinations. Bell + the dropdown's account
|
||||
// section move inside; the bell badge still surfaces on the trigger
|
||||
// itself via the parent navbar.
|
||||
export function MobileNavMenu({ user }: Props) {
|
||||
const { t } = useTranslation("journal");
|
||||
const [open, setOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
// Close the drawer on navigation. React Router pushes a new location
|
||||
// when a Link is followed; this effect picks that up.
|
||||
useEffect(() => {
|
||||
setOpen(false);
|
||||
}, [location.pathname]);
|
||||
|
||||
// Close on Escape; lock body scroll while open.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const linkClass = (path: string) => {
|
||||
const active = location.pathname === path || location.pathname.startsWith(path + "/");
|
||||
return `block rounded-md px-3 py-2 text-base font-medium ${
|
||||
active ? "bg-blue-50 text-blue-700" : "text-gray-700 hover:bg-gray-50"
|
||||
}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("nav.openMenu")}
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center justify-center rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.75}
|
||||
stroke="currentColor"
|
||||
className="h-6 w-6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-40 md:hidden">
|
||||
{/* Backdrop */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("nav.closeMenu")}
|
||||
onClick={() => setOpen(false)}
|
||||
className="absolute inset-0 bg-black/30"
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="absolute right-0 top-0 flex h-full w-72 max-w-[85%] flex-col bg-white shadow-xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar displayName={user.displayName} username={user.username} size="md" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-gray-900">
|
||||
{user.displayName ?? user.username}
|
||||
</p>
|
||||
<p className="truncate text-xs text-gray-500">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("nav.closeMenu")}
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md p-2 text-gray-500 hover:bg-gray-100"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.75}
|
||||
stroke="currentColor"
|
||||
className="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-2 py-3">
|
||||
<Link to="/feed" className={linkClass("/feed")}>
|
||||
{t("social.feed.title")}
|
||||
</Link>
|
||||
<Link to="/explore" className={linkClass("/explore")}>
|
||||
{t("nav.explore")}
|
||||
</Link>
|
||||
<Link to="/routes" className={linkClass("/routes")}>
|
||||
{t("nav.routes")}
|
||||
</Link>
|
||||
<Link to="/activities" className={linkClass("/activities")}>
|
||||
{t("nav.activities")}
|
||||
</Link>
|
||||
<Link to="/notifications" className={linkClass("/notifications")}>
|
||||
{t("notifications.title")}
|
||||
</Link>
|
||||
<hr className="my-2 border-gray-200" />
|
||||
<Link to={`/users/${user.username}`} className={linkClass(`/users/${user.username}`)}>
|
||||
{t("nav.profile")}
|
||||
</Link>
|
||||
<Link to="/settings" className={linkClass("/settings")}>
|
||||
{t("nav.settings")}
|
||||
</Link>
|
||||
<Form method="post" action="/auth/logout">
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full rounded-md px-3 py-2 text-left text-base font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
{t("nav.logout")}
|
||||
</button>
|
||||
</Form>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { ProfileStats } from "./ProfileStats.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ProfileStats", () => {
|
||||
it("renders nothing when there are no activities", () => {
|
||||
const { container } = render(
|
||||
<ProfileStats stats={{ count: 0, distance: 0, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders formatted totals", () => {
|
||||
const { container, getByText } = render(
|
||||
<ProfileStats
|
||||
stats={{ count: 42, distance: 123_400, elevationGain: 5120, duration: 9000, last4Weeks: 3 }}
|
||||
/>,
|
||||
);
|
||||
// Values come from the formatters (i18n-independent).
|
||||
expect(getByText("42")).toBeTruthy();
|
||||
expect(getByText("123 km")).toBeTruthy(); // >= 100 km → integer
|
||||
expect(getByText("↑ 5120 m")).toBeTruthy();
|
||||
expect(getByText("2h 30m")).toBeTruthy();
|
||||
// last-4-weeks line present when > 0
|
||||
expect(container.querySelector("p")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("omits the last-4-weeks line when zero", () => {
|
||||
const { container } = render(
|
||||
<ProfileStats stats={{ count: 5, distance: 1000, elevationGain: 0, duration: 0, last4Weeks: 0 }} />,
|
||||
);
|
||||
expect(container.querySelector("p")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { StatRow } from "./StatRow.tsx";
|
||||
import { formatDistanceKm, formatElevationM, formatDuration } from "~/lib/stats";
|
||||
|
||||
// Structural shape (mirrors ActivityStats from activities.server) so this
|
||||
// presentational component doesn't import from a `.server` module.
|
||||
export interface ProfileStatsData {
|
||||
count: number;
|
||||
distance: number;
|
||||
elevationGain: number;
|
||||
duration: number;
|
||||
last4Weeks: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lifetime roll-up header on the profile: activity count · distance · ascent ·
|
||||
* elapsed time, plus a "N in the last 4 weeks" line. Renders nothing when the
|
||||
* (viewer-scoped) count is zero.
|
||||
*/
|
||||
export function ProfileStats({ stats, className }: { stats: ProfileStatsData; className?: string }) {
|
||||
const { t } = useTranslation("journal");
|
||||
if (stats.count === 0) return null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<StatRow
|
||||
size="lg"
|
||||
items={[
|
||||
{ label: t("profileStats.activities"), value: String(stats.count) },
|
||||
{ label: t("profileStats.distance"), value: formatDistanceKm(stats.distance) },
|
||||
{ label: t("profileStats.ascent"), value: `↑ ${formatElevationM(stats.elevationGain)}` },
|
||||
{ label: t("profileStats.time"), value: formatDuration(stats.duration) },
|
||||
]}
|
||||
/>
|
||||
{stats.last4Weeks > 0 && (
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{t("profileStats.last4Weeks", { count: stats.last4Weeks })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,63 +1,9 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { MapContainer, TileLayer, GeoJSON, CircleMarker, useMap, useMapEvents } from "react-leaflet";
|
||||
import { MapContainer, TileLayer, GeoJSON, useMap } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import type { GeoJsonObject } from "geojson";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
/** Marker shown at the position the elevation chart is pointing at. */
|
||||
function ActiveMarker({ point }: { point: { lat: number; lng: number } | null | undefined }) {
|
||||
if (!point) return null;
|
||||
return (
|
||||
<CircleMarker
|
||||
center={[point.lat, point.lng]}
|
||||
radius={6}
|
||||
pathOptions={{ color: "#fff", weight: 2, fillColor: "#2563eb", fillOpacity: 1 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reports the route sample nearest the cursor so the chart can highlight it. */
|
||||
function HoverTracker({
|
||||
series,
|
||||
onHoverIndex,
|
||||
}: {
|
||||
series: Array<[number, number]>;
|
||||
onHoverIndex: (index: number | null) => void;
|
||||
}) {
|
||||
useMapEvents({
|
||||
mousemove(e) {
|
||||
const { lat, lng } = e.latlng;
|
||||
let best = -1;
|
||||
let bestDelta = Infinity;
|
||||
for (let i = 0; i < series.length; i++) {
|
||||
const [slat, slng] = series[i]!;
|
||||
const delta = (slat - lat) ** 2 + (slng - lng) ** 2;
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
onHoverIndex(best >= 0 ? best : null);
|
||||
},
|
||||
mouseout() {
|
||||
onHoverIndex(null);
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pans the map when the chart is clicked (centerOn.v bumps per click). */
|
||||
function Recenter({ centerOn }: { centerOn: { lat: number; lng: number; v: number } | null | undefined }) {
|
||||
const map = useMap();
|
||||
const lastV = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!centerOn || centerOn.v === lastV.current) return;
|
||||
lastV.current = centerOn.v;
|
||||
map.panTo([centerOn.lat, centerOn.lng], { animate: true });
|
||||
}, [centerOn, map]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function FitBounds({ data }: { data: GeoJsonObject }) {
|
||||
const map = useMap();
|
||||
const fitted = useRef(false);
|
||||
|
|
@ -134,35 +80,16 @@ interface RouteMapProps {
|
|||
dayBreaks?: number[];
|
||||
/** 1-based day number to highlight, or null for no highlight */
|
||||
highlightedDay?: number | null;
|
||||
/** Elevation-profile sync: marker position, route samples to hover-match, callbacks. */
|
||||
activePoint?: { lat: number; lng: number } | null;
|
||||
hoverSeries?: Array<[number, number]>;
|
||||
onHoverIndex?: (index: number | null) => void;
|
||||
centerOn?: { lat: number; lng: number; v: number } | null;
|
||||
}
|
||||
|
||||
export function RouteMapThumbnail({
|
||||
geojson,
|
||||
interactive,
|
||||
className,
|
||||
dayBreaks,
|
||||
highlightedDay,
|
||||
activePoint,
|
||||
hoverSeries,
|
||||
onHoverIndex,
|
||||
centerOn,
|
||||
}: RouteMapProps) {
|
||||
export function RouteMapThumbnail({ geojson, interactive, className, dayBreaks, highlightedDay }: RouteMapProps) {
|
||||
const data: GeoJsonObject = JSON.parse(geojson);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={[50, 10]}
|
||||
zoom={6}
|
||||
// `isolate` gives the Leaflet container its own stacking context so its
|
||||
// internal high z-indexes (panes ~200–700, zoom controls ~1000) stay
|
||||
// contained and can't paint over page overlays like the mobile nav
|
||||
// drawer's backdrop.
|
||||
className={`${className ?? "h-36 w-full rounded"} isolate`}
|
||||
className={className ?? "h-36 w-full rounded"}
|
||||
zoomControl={interactive ?? false}
|
||||
attributionControl={interactive ?? false}
|
||||
dragging={interactive ?? false}
|
||||
|
|
@ -187,11 +114,6 @@ export function RouteMapThumbnail({
|
|||
fullData={data}
|
||||
/>
|
||||
)}
|
||||
<ActiveMarker point={activePoint} />
|
||||
{hoverSeries && hoverSeries.length > 0 && onHoverIndex && (
|
||||
<HoverTracker series={hoverSeries} onHoverIndex={onHoverIndex} />
|
||||
)}
|
||||
<Recenter centerOn={centerOn} />
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
|
|
@ -220,7 +142,7 @@ function DayColoredRoute({ data, dayBreaks, highlightedDay }: { data: GeoJsonObj
|
|||
type: "Feature",
|
||||
geometry: { type: "LineString", coordinates: seg.coords },
|
||||
properties: {},
|
||||
} as GeoJsonObject;
|
||||
} as unknown as GeoJsonObject;
|
||||
return (
|
||||
<GeoJSON
|
||||
key={`${i}-${highlightedDay}`}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import type { SportType } from "@trails-cool/db/schema/journal";
|
||||
|
||||
// Emoji glyphs as lightweight, dependency-free sport icons. The localized
|
||||
// label carries the meaning; the glyph is decorative (aria-hidden).
|
||||
const SPORT_EMOJI: Record<SportType, string> = {
|
||||
hike: "🥾",
|
||||
walk: "🚶",
|
||||
run: "🏃",
|
||||
ride: "🚴",
|
||||
gravel: "🚲",
|
||||
mtb: "🚵",
|
||||
ski: "⛷️",
|
||||
other: "📍",
|
||||
};
|
||||
|
||||
/**
|
||||
* Small pill (glyph + localized label) shown next to an activity title on the
|
||||
* detail page, feed cards, and the profile list. Renders nothing when the
|
||||
* sport type is unset.
|
||||
*/
|
||||
export function SportBadge({
|
||||
sportType,
|
||||
className,
|
||||
}: {
|
||||
sportType: SportType | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("journal");
|
||||
if (!sportType) return null;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700${className ? ` ${className}` : ""}`}
|
||||
>
|
||||
<span aria-hidden>{SPORT_EMOJI[sportType]}</span>
|
||||
{t(`activities.sport.${sportType}`)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { StatRow } from "./StatRow.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("StatRow", () => {
|
||||
it("renders nothing for an empty item list", () => {
|
||||
const { container } = render(<StatRow items={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders each item's value and label, in order", () => {
|
||||
const { container } = render(
|
||||
<StatRow
|
||||
items={[
|
||||
{ label: "Distance", value: "30.0 km" },
|
||||
{ label: "Time", value: "1h 0m" },
|
||||
{ label: "Avg speed", value: "30.0 km/h" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const labels = [...container.querySelectorAll("dt")].map((el) => el.textContent);
|
||||
const values = [...container.querySelectorAll("dd")].map((el) => el.textContent);
|
||||
expect(labels).toEqual(["Distance", "Time", "Avg speed"]);
|
||||
expect(values).toEqual(["30.0 km", "1h 0m", "30.0 km/h"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import type { StatItem } from "~/lib/stats";
|
||||
|
||||
/**
|
||||
* The one shared headline-stat presentation. Surfaces pass an ordered list of
|
||||
* {value, label} items (built via `activityStatItems`); the component never
|
||||
* fetches or formats. `size="lg"` is the detail-page treatment; the default
|
||||
* compact size is for feed cards and the profile list.
|
||||
*/
|
||||
export function StatRow({
|
||||
items,
|
||||
size = "sm",
|
||||
className,
|
||||
}: {
|
||||
items: StatItem[];
|
||||
size?: "sm" | "lg";
|
||||
className?: string;
|
||||
}) {
|
||||
if (items.length === 0) return null;
|
||||
const valueCls =
|
||||
size === "lg"
|
||||
? "text-2xl font-bold text-gray-900"
|
||||
: "text-sm font-semibold text-gray-900";
|
||||
const gap = size === "lg" ? "gap-6" : "gap-x-4 gap-y-1";
|
||||
return (
|
||||
<dl className={`flex flex-wrap ${gap}${className ? ` ${className}` : ""}`}>
|
||||
{items.map((it) => (
|
||||
<div key={it.label}>
|
||||
<dd className={valueCls}>{it.value}</dd>
|
||||
<dt className="text-xs text-gray-500">{it.label}</dt>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { SurfaceBreakdown } from "./SurfaceBreakdown.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("SurfaceBreakdown", () => {
|
||||
it("renders nothing without a breakdown", () => {
|
||||
const { container } = render(<SurfaceBreakdown breakdown={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing when every bucket is zero", () => {
|
||||
const { container } = render(<SurfaceBreakdown breakdown={{ surface: { asphalt: 0 }, highway: {} }} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a legend item per non-zero category with its percentage", () => {
|
||||
const { container, getByText } = render(
|
||||
<SurfaceBreakdown breakdown={{ surface: { asphalt: 6000, gravel: 4000 }, highway: { residential: 10000 } }} />,
|
||||
);
|
||||
// 2 surface + 1 waytype = 3 legend entries
|
||||
expect(container.querySelectorAll("li")).toHaveLength(3);
|
||||
expect(getByText(/60% · 6\.0 km/)).toBeTruthy();
|
||||
expect(getByText(/40% · 4\.0 km/)).toBeTruthy();
|
||||
expect(getByText(/100% · 10\.0 km/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sorts segments largest-first within a dimension", () => {
|
||||
const { container } = render(
|
||||
<SurfaceBreakdown breakdown={{ surface: { gravel: 3000, asphalt: 7000 }, highway: {} }} />,
|
||||
);
|
||||
// first bar's first segment is the larger (asphalt 70%)
|
||||
const firstBar = container.querySelector("div.flex.h-3");
|
||||
const widths = [...firstBar!.querySelectorAll("div")].map((d) => (d as HTMLElement).style.width);
|
||||
expect(widths[0]).toBe("70%");
|
||||
expect(widths[1]).toBe("30%");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
SURFACE_COLORS,
|
||||
DEFAULT_SURFACE_COLOR,
|
||||
HIGHWAY_COLORS,
|
||||
DEFAULT_HIGHWAY_COLOR,
|
||||
} from "@trails-cool/map-core";
|
||||
import { formatDistanceKm } from "~/lib/stats";
|
||||
|
||||
export interface SurfaceBreakdownData {
|
||||
surface: Record<string, number>;
|
||||
highway: Record<string, number>;
|
||||
}
|
||||
|
||||
function Bar({
|
||||
title,
|
||||
data,
|
||||
colorFor,
|
||||
}: {
|
||||
title: string;
|
||||
data: Record<string, number>;
|
||||
colorFor: (category: string) => string;
|
||||
}) {
|
||||
const { t } = useTranslation("journal");
|
||||
const entries = Object.entries(data)
|
||||
.filter(([, m]) => m > 0)
|
||||
.sort((a, b) => b[1] - a[1]);
|
||||
const total = entries.reduce((s, [, m]) => s + m, 0);
|
||||
if (total <= 0) return null;
|
||||
|
||||
const label = (cat: string) =>
|
||||
cat === "unknown"
|
||||
? t("surface.other")
|
||||
: t(`surface.cat.${cat}`, { defaultValue: cat.replace(/_/g, " ") });
|
||||
|
||||
return (
|
||||
<div className="mt-3 first:mt-0">
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{title}</p>
|
||||
<div className="flex h-3 w-full overflow-hidden rounded">
|
||||
{entries.map(([cat, m]) => (
|
||||
<div
|
||||
key={cat}
|
||||
style={{ width: `${(m / total) * 100}%`, backgroundColor: colorFor(cat) }}
|
||||
title={`${label(cat)} · ${formatDistanceKm(m)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600">
|
||||
{entries.map(([cat, m]) => (
|
||||
<li key={cat} className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-sm"
|
||||
style={{ backgroundColor: colorFor(cat) }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span>{label(cat)}</span>
|
||||
<span className="tabular-nums text-gray-400">
|
||||
{Math.round((m / total) * 100)}% · {formatDistanceKm(m)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface + waytype proportion bars (route-surface-breakdown). Renders nothing
|
||||
* when there's no breakdown data. Colours come from the shared map-core
|
||||
* palettes; unknown tags collapse into "other".
|
||||
*/
|
||||
export function SurfaceBreakdown({
|
||||
breakdown,
|
||||
className,
|
||||
}: {
|
||||
breakdown: SurfaceBreakdownData | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation("journal");
|
||||
if (!breakdown) return null;
|
||||
const hasSurface = Object.values(breakdown.surface).some((m) => m > 0);
|
||||
const hasHighway = Object.values(breakdown.highway).some((m) => m > 0);
|
||||
if (!hasSurface && !hasHighway) return null;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Bar
|
||||
title={t("surface.surface")}
|
||||
data={breakdown.surface}
|
||||
colorFor={(c) => SURFACE_COLORS[c] ?? DEFAULT_SURFACE_COLOR}
|
||||
/>
|
||||
<Bar
|
||||
title={t("surface.waytype")}
|
||||
data={breakdown.highway}
|
||||
colorFor={(c) => HIGHWAY_COLORS[c] ?? DEFAULT_HIGHWAY_COLOR}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { render, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { WeeklyDistanceChart } from "./WeeklyDistanceChart.tsx";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const weeks = (distances: number[]) =>
|
||||
distances.map((distance, i) => ({ weekStart: `2026-04-${String(i + 1).padStart(2, "0")}`, distance }));
|
||||
|
||||
describe("WeeklyDistanceChart", () => {
|
||||
it("renders nothing when every week is zero", () => {
|
||||
const { container } = render(<WeeklyDistanceChart weeks={weeks([0, 0, 0])} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the chart with a bar only for non-zero weeks", () => {
|
||||
const { container } = render(<WeeklyDistanceChart weeks={weeks([5000, 0, 2500, 0])} />);
|
||||
expect(container.querySelector("svg")).not.toBeNull();
|
||||
// bars only for the two non-zero weeks; empty weeks keep their slot via the track rect
|
||||
expect(container.querySelectorAll("[data-week-bar]")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("tops the y-scale at the busiest week", () => {
|
||||
const { getByText } = render(<WeeklyDistanceChart weeks={weeks([5000, 10000, 2500])} />);
|
||||
// peak gridline label = 10 km (>= 10 → integer)
|
||||
expect(getByText("10 km")).toBeTruthy();
|
||||
// half gridline
|
||||
expect(getByText("5.0 km")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a hover readout with the week's distance", () => {
|
||||
// max 8 km → axis labels are 8.0 / 4.0 / 0 km; "3.0 km" is unique to the
|
||||
// readout for week 0, so it only appears once that week is hovered.
|
||||
const { container, queryByText } = render(<WeeklyDistanceChart weeks={weeks([3000, 8000])} />);
|
||||
expect(queryByText("3.0 km")).toBeNull();
|
||||
const hit = container.querySelectorAll("rect[fill='transparent']")[0]!;
|
||||
fireEvent.mouseEnter(hit);
|
||||
expect(queryByText("3.0 km")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { formatDistanceKm } from "~/lib/stats";
|
||||
|
||||
export interface WeeklyDistanceBucket {
|
||||
weekStart: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
// SVG layout (user units; rendered responsive via viewBox).
|
||||
const W = 480;
|
||||
const H = 132;
|
||||
const PAD = { top: 10, right: 6, bottom: 18, left: 36 };
|
||||
const PLOT_W = W - PAD.left - PAD.right;
|
||||
const PLOT_H = H - PAD.top - PAD.bottom;
|
||||
const BASE_Y = PAD.top + PLOT_H;
|
||||
|
||||
function axisLabel(km: number): string {
|
||||
if (km <= 0) return "0";
|
||||
return km < 10 ? `${km.toFixed(1)} km` : `${Math.round(km)} km`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Weekly-distance bar chart for the profile (last N weeks, oldest → newest).
|
||||
* Gridlines + a y-scale topped at the busiest week, faint per-week tracks so
|
||||
* the 12-week axis is always visible (empty weeks read as gaps, not nothing),
|
||||
* and a hover readout naming the week + its distance. Hidden when there is no
|
||||
* distance in the window.
|
||||
*/
|
||||
export function WeeklyDistanceChart({
|
||||
weeks,
|
||||
className,
|
||||
}: {
|
||||
weeks: WeeklyDistanceBucket[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("journal");
|
||||
const [hover, setHover] = useState<number | null>(null);
|
||||
|
||||
const maxM = weeks.reduce((m, w) => Math.max(m, w.distance), 0);
|
||||
if (maxM <= 0) return null;
|
||||
|
||||
const maxKm = maxM / 1000;
|
||||
const n = weeks.length;
|
||||
const colW = PLOT_W / n;
|
||||
const barW = colW * 0.6;
|
||||
const x = (i: number) => PAD.left + i * colW + (colW - barW) / 2;
|
||||
const yFor = (m: number) => BASE_Y - (m / maxM) * PLOT_H;
|
||||
|
||||
const fmtWeek = (iso: string) =>
|
||||
new Date(`${iso}T00:00:00`).toLocaleDateString(i18n.language, { month: "short", day: "numeric" });
|
||||
|
||||
const gridFracs = [0, 0.5, 1];
|
||||
const active = hover != null ? weeks[hover] : null;
|
||||
const first = weeks[0];
|
||||
const last = weeks[n - 1];
|
||||
const firstLabel = first ? fmtWeek(first.weekStart) : "";
|
||||
const lastLabel = last ? fmtWeek(last.weekStart) : "";
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="mb-1 flex items-baseline justify-between text-xs text-gray-500">
|
||||
<span>{t("profileStats.weeklyDistance")}</span>
|
||||
{active && (
|
||||
<span className="tabular-nums text-gray-700">
|
||||
{t("profileStats.weekOf", { date: fmtWeek(active.weekStart) })} ·{" "}
|
||||
<span className="font-semibold">{formatDistanceKm(active.distance)}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
className="h-28 w-full"
|
||||
role="img"
|
||||
aria-label={t("profileStats.weeklyDistance")}
|
||||
onMouseLeave={() => setHover(null)}
|
||||
>
|
||||
{/* gridlines + y-axis labels (0 · half · peak) */}
|
||||
{gridFracs.map((f) => {
|
||||
const yy = BASE_Y - f * PLOT_H;
|
||||
return (
|
||||
<g key={f}>
|
||||
<line x1={PAD.left} y1={yy} x2={W - PAD.right} y2={yy} stroke="#e5e7eb" strokeWidth="1" />
|
||||
<text x={PAD.left - 5} y={yy + 3} textAnchor="end" fontSize="9" fill="#9ca3af">
|
||||
{axisLabel(maxKm * f)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{weeks.map((w, i) => {
|
||||
const isActive = hover === i;
|
||||
return (
|
||||
<g key={w.weekStart}>
|
||||
{/* faint week-slot track so empty weeks stay visible */}
|
||||
<rect
|
||||
x={x(i)}
|
||||
y={PAD.top}
|
||||
width={barW}
|
||||
height={PLOT_H}
|
||||
rx="1.5"
|
||||
fill={isActive ? "#dbeafe" : "#f3f4f6"}
|
||||
/>
|
||||
{/* distance bar */}
|
||||
{w.distance > 0 && (
|
||||
<rect
|
||||
data-week-bar
|
||||
x={x(i)}
|
||||
y={yFor(w.distance)}
|
||||
width={barW}
|
||||
height={BASE_Y - yFor(w.distance)}
|
||||
rx="1.5"
|
||||
fill={isActive ? "#1d4ed8" : "#3b82f6"}
|
||||
/>
|
||||
)}
|
||||
{/* full-height hover hit area */}
|
||||
<rect
|
||||
x={PAD.left + i * colW}
|
||||
y={PAD.top}
|
||||
width={colW}
|
||||
height={PLOT_H}
|
||||
fill="transparent"
|
||||
onMouseEnter={() => setHover(i)}
|
||||
>
|
||||
<title>{`${fmtWeek(w.weekStart)} · ${formatDistanceKm(w.distance)}`}</title>
|
||||
</rect>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
<div className="flex justify-between px-px text-[10px] text-gray-400">
|
||||
<span>{firstLabel}</span>
|
||||
<span>{lastLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { useEffect } from "react";
|
||||
import { useRevalidator } from "react-router";
|
||||
|
||||
/**
|
||||
* Live-updates a route/activity detail page when its async surface backfill
|
||||
* completes. Subscribes to `/api/events` and, on a `surface_breakdown` event
|
||||
* matching this row, re-runs the loader (so the bars appear without a reload).
|
||||
*
|
||||
* Enable only while it's worth it — i.e. the viewer is the owner (the backfill
|
||||
* emits to the owner's user stream) and the breakdown isn't present yet. Once
|
||||
* the breakdown lands, the loader revalidates, `enabled` flips false, and the
|
||||
* connection is torn down.
|
||||
*/
|
||||
export function useSurfaceBackfillUpdates(
|
||||
kind: "route" | "activity",
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
): void {
|
||||
const revalidator = useRevalidator();
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (typeof EventSource === "undefined") return;
|
||||
const es = new EventSource("/api/events");
|
||||
const onEvent = (e: MessageEvent) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as { kind?: string; id?: string };
|
||||
if (parsed.kind === kind && parsed.id === id) revalidator.revalidate();
|
||||
} catch {
|
||||
// Malformed payload — ignore.
|
||||
}
|
||||
};
|
||||
es.addEventListener("surface_breakdown", onEvent as EventListener);
|
||||
return () => {
|
||||
es.removeEventListener("surface_breakdown", onEvent as EventListener);
|
||||
es.close();
|
||||
};
|
||||
}, [kind, id, enabled, revalidator]);
|
||||
}
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Subscribes to /api/events for live `notifications.unread` updates.
|
||||
* Returns the live unread count, seeded with the loader-provided
|
||||
* baseline so the badge renders correctly before the SSE handshake
|
||||
* completes. Native EventSource handles reconnects with the
|
||||
* server-suggested `retry:` interval.
|
||||
*/
|
||||
export function useUnreadNotifications(initialCount: number, signedIn: boolean): number {
|
||||
const [count, setCount] = useState(initialCount);
|
||||
|
||||
// Keep the in-component count in sync if the loader-provided baseline
|
||||
// changes (e.g., on navigation).
|
||||
useEffect(() => {
|
||||
setCount(initialCount);
|
||||
}, [initialCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn) return;
|
||||
if (typeof EventSource === "undefined") return;
|
||||
const es = new EventSource("/api/events");
|
||||
const onUnread = (e: MessageEvent) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.data) as { count: number };
|
||||
if (typeof parsed.count === "number") setCount(parsed.count);
|
||||
} catch {
|
||||
// Malformed payload — ignore.
|
||||
}
|
||||
};
|
||||
es.addEventListener("notifications.unread", onUnread as EventListener);
|
||||
return () => {
|
||||
es.removeEventListener("notifications.unread", onUnread as EventListener);
|
||||
es.close();
|
||||
};
|
||||
}, [signedIn]);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { ensureUserKeypair, listUsersWithoutKeypair } from "../lib/federation-keys.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* One-shot backfill: generate a federation keypair for every user who
|
||||
* predates federation (spec: "Existing-user backfill at deploy"). The
|
||||
* server enqueues this once at startup whenever FEDERATION_ENABLED is
|
||||
* on (singleton-keyed, so repeat startups don't stack runs); each run
|
||||
* only touches users whose public_key IS NULL, so re-runs are no-ops.
|
||||
* New users get keys at registration and never appear in this workload.
|
||||
*/
|
||||
export const backfillUserKeypairsJob = defineJournalJob({
|
||||
name: "backfill-user-keypairs",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 300,
|
||||
async handler() {
|
||||
const ids = await listUsersWithoutKeypair();
|
||||
let generated = 0;
|
||||
for (const id of ids) {
|
||||
if (await ensureUserKeypair(id)) generated++;
|
||||
}
|
||||
logger.info({ candidates: ids.length, generated }, "backfill-user-keypairs");
|
||||
return { candidates: ids.length, generated };
|
||||
},
|
||||
});
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { lt } from "drizzle-orm";
|
||||
import { consumedJwtJti } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Daily cleanup for the JWT replay-protection table. Each row was
|
||||
* inserted by `verifyRouteToken` to mark a token as consumed; once
|
||||
* the token's `exp` claim has passed, the row is no longer useful
|
||||
* (the JWT itself would fail signature verification before reaching
|
||||
* the consume step). Bound the table to keep it tiny.
|
||||
*
|
||||
* See planner-audit #2 Phase B.
|
||||
*/
|
||||
export const consumedJtiSweepJob = defineJournalJob({
|
||||
name: "consumed-jti-sweep",
|
||||
cron: "45 3 * * *", // daily at 03:45 UTC (offset from notifications-purge)
|
||||
retryLimit: 1,
|
||||
expireInSeconds: 60,
|
||||
async handler() {
|
||||
const db = getDb();
|
||||
const result = await db
|
||||
.delete(consumedJwtJti)
|
||||
.where(lt(consumedJwtJti.expiresAt, new Date()))
|
||||
.returning({ jti: consumedJwtJti.jti });
|
||||
const purged = result.length;
|
||||
logger.info({ purged }, "consumed-jti-sweep");
|
||||
return { purged };
|
||||
},
|
||||
});
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { activities, users } from "@trails-cool/db/schema/journal";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { getOrigin } from "../lib/config.server.ts";
|
||||
import { getFederation } from "../lib/federation.server.ts";
|
||||
import {
|
||||
activityToCreate,
|
||||
activityToDelete,
|
||||
type FederatableActivity,
|
||||
} from "../lib/federation-objects.server.ts";
|
||||
import {
|
||||
getCachedRemoteActor,
|
||||
upsertRemoteActor,
|
||||
type DeliveryPayload,
|
||||
} from "../lib/federation-delivery.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
import { federationDeliveryTotal } from "../lib/metrics.server.ts";
|
||||
|
||||
/**
|
||||
* Outbound pacing (spec 5.5): never exceed 1 request/second per remote
|
||||
* host. In-process map is sufficient — pg-boss works this queue
|
||||
* sequentially in the single journal process.
|
||||
*/
|
||||
const lastSendPerHost = new Map<string, number>();
|
||||
const MIN_INTERVAL_MS = 1000;
|
||||
|
||||
async function paceHost(host: string): Promise<void> {
|
||||
const last = lastSendPerHost.get(host) ?? 0;
|
||||
const wait = last + MIN_INTERVAL_MS - Date.now();
|
||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||
lastSendPerHost.set(host, Date.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver one activity to one remote follower's inbox (spec 5.3/5.4).
|
||||
* One job per (activity, recipient) so each delivery retries with
|
||||
* exponential backoff independently (configured at enqueue time —
|
||||
* see enqueueActivityDeliveries). A thrown error marks the attempt
|
||||
* failed and pg-boss retries; exhausting the budget is the permanent
|
||||
* failure, logged by the final catch.
|
||||
*/
|
||||
export const deliverActivityJob = defineJournalJob({
|
||||
name: "deliver-activity",
|
||||
expireInSeconds: 60,
|
||||
async handler(jobs) {
|
||||
for (const job of jobs) {
|
||||
const p = job.data;
|
||||
try {
|
||||
const outcome = await deliverOne(p);
|
||||
federationDeliveryTotal.inc({ outcome });
|
||||
} catch (err) {
|
||||
federationDeliveryTotal.inc({ outcome: "failed" });
|
||||
logger.warn(
|
||||
{ err, action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
|
||||
"deliver-activity attempt failed (pg-boss will retry until budget exhausted)",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function deliverOne(p: DeliveryPayload): Promise<"delivered" | "skipped"> {
|
||||
const federation = getFederation();
|
||||
const ctx = federation.createContext(new URL(getOrigin()), undefined);
|
||||
|
||||
// Build the activity to send. For `create`, re-read the row at
|
||||
// delivery time: if it was deleted or un-publicized since enqueue,
|
||||
// skip rather than leak.
|
||||
let activity;
|
||||
if (p.action === "create") {
|
||||
const db = getDb();
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(and(eq(activities.id, p.activityId!), eq(activities.visibility, "public")))
|
||||
.limit(1);
|
||||
if (!row) {
|
||||
logger.info({ objectIri: p.objectIri }, "deliver-activity: activity gone or non-public; skipping");
|
||||
return "skipped";
|
||||
}
|
||||
// Spec 9.3: flipping the profile to private stops federation — also
|
||||
// for deliveries already enqueued when the flip happened.
|
||||
const [owner] = await db
|
||||
.select({ profileVisibility: users.profileVisibility })
|
||||
.from(users)
|
||||
.where(eq(users.username, p.ownerUsername))
|
||||
.limit(1);
|
||||
if (!owner || owner.profileVisibility !== "public") {
|
||||
logger.info({ objectIri: p.objectIri }, "deliver-activity: owner no longer public; skipping");
|
||||
return "skipped";
|
||||
}
|
||||
activity = activityToCreate(row as FederatableActivity, p.ownerUsername);
|
||||
} else {
|
||||
activity = activityToDelete(p.objectIri, p.ownerUsername);
|
||||
}
|
||||
|
||||
// Resolve the recipient's inbox: cached remote_actors row first,
|
||||
// actor-document fetch as fallback (which also primes the cache).
|
||||
const recipientIri = new URL(p.recipientActorIri);
|
||||
let inboxUrl: URL;
|
||||
const cached = await getCachedRemoteActor(p.recipientActorIri);
|
||||
if (cached?.inboxUrl) {
|
||||
inboxUrl = new URL(cached.inboxUrl);
|
||||
} else {
|
||||
await paceHost(recipientIri.host);
|
||||
const actor = await ctx.lookupObject(recipientIri);
|
||||
const fetchedInbox =
|
||||
actor != null && "inboxId" in actor ? (actor.inboxId as URL | null) : null;
|
||||
if (!fetchedInbox) {
|
||||
throw new Error(`deliver-activity: cannot resolve inbox for ${p.recipientActorIri}`);
|
||||
}
|
||||
inboxUrl = fetchedInbox;
|
||||
await upsertRemoteActor({
|
||||
actorIri: p.recipientActorIri,
|
||||
inboxUrl: inboxUrl.href,
|
||||
domain: recipientIri.host,
|
||||
});
|
||||
}
|
||||
|
||||
await paceHost(inboxUrl.host);
|
||||
await ctx.sendActivity(
|
||||
// Identifier and username are the same thing in our actor model.
|
||||
{ identifier: p.ownerUsername },
|
||||
{ id: recipientIri, inboxId: inboxUrl },
|
||||
activity,
|
||||
);
|
||||
logger.info(
|
||||
{ action: p.action, objectIri: p.objectIri, recipient: p.recipientActorIri },
|
||||
"deliver-activity: delivered",
|
||||
);
|
||||
return "delivered";
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import {
|
||||
DEMO_BACKFILL_TARGET,
|
||||
DEMO_DAILY_CAP,
|
||||
|
|
@ -21,7 +21,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
* 3. Otherwise apply the decide-to-walk gate (local hour + p=0.09) and
|
||||
* daily cap; on pass, insert one route+activity via `generateOneWalk`.
|
||||
*/
|
||||
export const demoBotGenerateJob = defineJournalJob({
|
||||
export const demoBotGenerateJob: JobDefinition = {
|
||||
name: "demo-bot-generate",
|
||||
cron: "0,30 * * * *",
|
||||
retryLimit: 1,
|
||||
|
|
@ -57,4 +57,4 @@ export const demoBotGenerateJob = defineJournalJob({
|
|||
await refreshDemoBotGauges();
|
||||
return { mode: "single", routeId: id };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import type { JobDefinition } from "@trails-cool/jobs";
|
||||
import {
|
||||
demoRetentionDays,
|
||||
isDemoBotEnabled,
|
||||
|
|
@ -11,7 +11,7 @@ import { logger } from "../lib/logger.server.ts";
|
|||
* Daily prune. Deletes synthetic rows older than
|
||||
* `DEMO_BOT_RETENTION_DAYS` (default 14). Never touches real users.
|
||||
*/
|
||||
export const demoBotPruneJob = defineJournalJob({
|
||||
export const demoBotPruneJob: JobDefinition = {
|
||||
name: "demo-bot-prune",
|
||||
cron: "15 3 * * *",
|
||||
retryLimit: 1,
|
||||
|
|
@ -24,4 +24,4 @@ export const demoBotPruneJob = defineJournalJob({
|
|||
await refreshDemoBotGauges();
|
||||
return { days, ...counts };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { sweepProcessedActivities } from "../lib/federation-replay.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Daily cleanup of federation_processed_activities rows older than 30
|
||||
* days (spec: federation-operations "Inbound replay defense"). Replays of
|
||||
* activities that old are already rejected by HTTP-signature date
|
||||
* freshness, so the dedup record is no longer needed; this keeps the
|
||||
* table bounded.
|
||||
*/
|
||||
export const federationDedupSweepJob = defineJournalJob({
|
||||
name: "federation-dedup-sweep",
|
||||
cron: "30 4 * * *", // daily at 04:30 UTC (offset from federation-kv-sweep at 04:15)
|
||||
retryLimit: 1,
|
||||
expireInSeconds: 60,
|
||||
async handler() {
|
||||
const purged = await sweepProcessedActivities();
|
||||
logger.info({ purged }, "federation-dedup-sweep");
|
||||
return { purged };
|
||||
},
|
||||
});
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { PostgresKvStore } from "../lib/federation-kv.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Daily cleanup of expired federation_kv rows (Fedify replay-protection
|
||||
* nonces and caches carry TTLs; reads already filter expired rows, this
|
||||
* keeps the table from growing unbounded).
|
||||
*/
|
||||
export const federationKvSweepJob = defineJournalJob({
|
||||
name: "federation-kv-sweep",
|
||||
cron: "15 4 * * *", // daily at 04:15 UTC (offset from the other sweeps)
|
||||
retryLimit: 1,
|
||||
expireInSeconds: 60,
|
||||
async handler() {
|
||||
const purged = await new PostgresKvStore().sweepExpired();
|
||||
logger.info({ purged }, "federation-kv-sweep");
|
||||
return { purged };
|
||||
},
|
||||
});
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
import { runGarminActivityImport } from "../lib/connected-services/providers/garmin/import.server.ts";
|
||||
|
||||
// Garmin webhook notifications enqueue here (spec: garmin-import,
|
||||
// "Push-notification activity import"): the webhook answers 200
|
||||
// immediately and this job does the slow part — authorized file
|
||||
// download, FIT→GPX, activity creation. Backfill bursts deliver many
|
||||
// notifications at once; the queue absorbs them and pg-boss retries
|
||||
// transient download failures.
|
||||
export const garminImportActivityJob = defineJournalJob({
|
||||
name: "garmin-import-activity",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 300,
|
||||
async handler(jobs) {
|
||||
const batch = Array.isArray(jobs) ? jobs : [jobs];
|
||||
for (const job of batch) {
|
||||
const data = job.data;
|
||||
try {
|
||||
await runGarminActivityImport(data);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{ err, externalId: data.externalId },
|
||||
"garmin-import-activity failed (pg-boss will retry)",
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { and, lt, inArray, count } from "drizzle-orm";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { importBatches, type ImportBatchStatus } from "@trails-cool/db/schema/journal";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
const STALE_MS = 10 * 60 * 1000;
|
||||
const STALE_STATUSES: ImportBatchStatus[] = ["pending", "running"];
|
||||
|
||||
export const importBatchesSweepJob = defineJournalJob({
|
||||
name: "import-batches-sweep",
|
||||
cron: "* * * * *",
|
||||
retryLimit: 0,
|
||||
expireInSeconds: 55,
|
||||
async handler() {
|
||||
const db = getDb();
|
||||
const cutoff = new Date(Date.now() - STALE_MS);
|
||||
const staleFilter = and(
|
||||
inArray(importBatches.status, STALE_STATUSES),
|
||||
lt(importBatches.startedAt, cutoff),
|
||||
);
|
||||
|
||||
// Skip the write when nothing is stale to avoid an unconditional UPDATE every minute.
|
||||
const rows = await db
|
||||
.select({ staleCount: count() })
|
||||
.from(importBatches)
|
||||
.where(staleFilter);
|
||||
if ((rows[0]?.staleCount ?? 0) === 0) return;
|
||||
|
||||
const result = await db
|
||||
.update(importBatches)
|
||||
.set({
|
||||
status: "failed" satisfies ImportBatchStatus,
|
||||
errorMessage: "Import timed out — the server may have restarted mid-import. Click 'Run again' to retry.",
|
||||
completedAt: new Date(),
|
||||
})
|
||||
.where(staleFilter)
|
||||
.returning({ id: importBatches.id });
|
||||
|
||||
logger.info({ count: result.length }, "import-batches-sweep: marked stale batches as failed");
|
||||
},
|
||||
});
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("../lib/logger.server.ts", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
const withFreshCredentials = vi.fn();
|
||||
vi.mock("../lib/connected-services/manager.ts", () => ({
|
||||
withFreshCredentials: (...args: unknown[]) => withFreshCredentials(...args),
|
||||
}));
|
||||
|
||||
const runKomootBulkImport = vi.fn();
|
||||
const markBatchFailed = vi.fn();
|
||||
vi.mock("../lib/komoot-bulk-import.server.ts", () => ({
|
||||
runKomootBulkImport: (...args: unknown[]) => runKomootBulkImport(...args),
|
||||
markBatchFailed: (...args: unknown[]) => markBatchFailed(...args),
|
||||
}));
|
||||
|
||||
import { komootBulkImportJob } from "./komoot-bulk-import.ts";
|
||||
|
||||
type HandlerJobs = Parameters<typeof komootBulkImportJob.handler>[0];
|
||||
|
||||
function jobWith(data: unknown): HandlerJobs {
|
||||
return [{ id: "j1", data }] as unknown as HandlerJobs;
|
||||
}
|
||||
|
||||
const PAYLOAD = { batchId: "batch-1", userId: "user-1", serviceId: "svc-1" };
|
||||
|
||||
describe("komoot-bulk-import job", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("resolves credentials through the manager — only the serviceId crosses the queue", async () => {
|
||||
const creds = { mode: "public", komootUserId: "k1" };
|
||||
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn(creds));
|
||||
runKomootBulkImport.mockResolvedValue(undefined);
|
||||
|
||||
await komootBulkImportJob.handler(jobWith(PAYLOAD));
|
||||
|
||||
expect(withFreshCredentials).toHaveBeenCalledWith("svc-1", expect.any(Function));
|
||||
expect(runKomootBulkImport).toHaveBeenCalledWith("batch-1", "user-1", creds);
|
||||
expect(markBatchFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the batch failed and rethrows when credential resolution fails", async () => {
|
||||
withFreshCredentials.mockRejectedValue(new Error("needs relink"));
|
||||
|
||||
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("needs relink");
|
||||
|
||||
expect(runKomootBulkImport).not.toHaveBeenCalled();
|
||||
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "needs relink");
|
||||
});
|
||||
|
||||
it("also marks failed when the import itself rejects (markBatchFailed is a no-op on terminal batches)", async () => {
|
||||
withFreshCredentials.mockImplementation(async (_serviceId, fn) => fn({ mode: "public" }));
|
||||
runKomootBulkImport.mockRejectedValue(new Error("komoot 500"));
|
||||
|
||||
await expect(komootBulkImportJob.handler(jobWith(PAYLOAD))).rejects.toThrow("komoot 500");
|
||||
expect(markBatchFailed).toHaveBeenCalledWith("batch-1", "komoot 500");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
import { withFreshCredentials } from "../lib/connected-services/manager.ts";
|
||||
import {
|
||||
markBatchFailed,
|
||||
runKomootBulkImport,
|
||||
type KomootCreds,
|
||||
} from "../lib/komoot-bulk-import.server.ts";
|
||||
|
||||
export const komootBulkImportJob = defineJournalJob({
|
||||
name: "komoot-bulk-import",
|
||||
retryLimit: 1,
|
||||
expireInSeconds: 1800,
|
||||
async handler(jobs) {
|
||||
const batch = Array.isArray(jobs) ? jobs : [jobs];
|
||||
for (const job of batch) {
|
||||
const { batchId, userId, serviceId } = job.data;
|
||||
logger.info({ batchId, userId }, "komoot bulk import job started");
|
||||
try {
|
||||
// Credentials are resolved through the ConnectedServiceManager at
|
||||
// execution time — the payload carries only the serviceId, so
|
||||
// nothing credential-shaped sits in the job table and a relink
|
||||
// between enqueue and execution is picked up here.
|
||||
await withFreshCredentials(serviceId, (creds) =>
|
||||
runKomootBulkImport(batchId, userId, creds as KomootCreds),
|
||||
);
|
||||
} catch (err) {
|
||||
// runKomootBulkImport marks its own failures; this covers errors
|
||||
// before it ran (service missing/not active/needs relink), where
|
||||
// the batch would otherwise stay "pending" forever.
|
||||
await markBatchFailed(batchId, err instanceof Error ? err.message : String(err));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
import { describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { activities, follows, users } from "@trails-cool/db/schema/journal";
|
||||
import { fanout } from "./notifications-fanout.ts";
|
||||
import { listForUser } from "../lib/notifications.server.ts";
|
||||
|
||||
// Same opt-in flag as the rest of the notifications integration tests.
|
||||
const runIntegration = process.env.NOTIFICATIONS_INTEGRATION === "1";
|
||||
|
||||
async function makeUser(opts: { username: string; profileVisibility?: "public" | "private" }) {
|
||||
const db = getDb();
|
||||
const id = randomUUID();
|
||||
await db.insert(users).values({
|
||||
id,
|
||||
email: `${opts.username}@example.test`,
|
||||
username: opts.username,
|
||||
domain: "test.local",
|
||||
profileVisibility: opts.profileVisibility ?? "public",
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function makeFollow(followerId: string, followedId: string, opts: { accepted: boolean } = { accepted: true }) {
|
||||
const db = getDb();
|
||||
const followedUsername = (await db.select({ u: users.username }).from(users).where(eq(users.id, followedId)))[0]!.u;
|
||||
await db.insert(follows).values({
|
||||
id: randomUUID(),
|
||||
followerId,
|
||||
followedActorIri: `https://test.local/users/${followedUsername}`,
|
||||
followedUserId: followedId,
|
||||
acceptedAt: opts.accepted ? new Date() : null,
|
||||
});
|
||||
}
|
||||
|
||||
async function makeActivity(ownerId: string, visibility: "public" | "private" | "unlisted" = "public", name = "Walk") {
|
||||
const db = getDb();
|
||||
const id = randomUUID();
|
||||
await db.insert(activities).values({
|
||||
id,
|
||||
ownerId,
|
||||
name,
|
||||
visibility,
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
async function wipe() {
|
||||
const db = getDb();
|
||||
await db.execute(sql`DELETE FROM journal.notifications WHERE recipient_user_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
|
||||
await db.execute(sql`DELETE FROM journal.activities WHERE owner_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
|
||||
await db.execute(sql`DELETE FROM journal.follows WHERE follower_id IN (SELECT id FROM journal.users WHERE email LIKE '%@example.test')`);
|
||||
await db.execute(sql`DELETE FROM journal.users WHERE email LIKE '%@example.test'`);
|
||||
}
|
||||
|
||||
describe.skipIf(!runIntegration)("notifications-fanout integration", () => {
|
||||
beforeAll(async () => {
|
||||
const db = getDb();
|
||||
await db.execute(sql`SELECT 1 FROM journal.notifications LIMIT 0`);
|
||||
});
|
||||
afterEach(wipe);
|
||||
|
||||
it("inserts exactly one row per accepted follower; pending followers are skipped", async () => {
|
||||
const owner = await makeUser({ username: `nf_o_${Date.now()}` });
|
||||
const a1 = await makeUser({ username: `nf_a1_${Date.now()}` });
|
||||
const a2 = await makeUser({ username: `nf_a2_${Date.now()}` });
|
||||
const p1 = await makeUser({ username: `nf_p1_${Date.now()}` });
|
||||
const p2 = await makeUser({ username: `nf_p2_${Date.now()}` });
|
||||
await makeFollow(a1, owner, { accepted: true });
|
||||
await makeFollow(a2, owner, { accepted: true });
|
||||
await makeFollow(p1, owner, { accepted: false });
|
||||
await makeFollow(p2, owner, { accepted: false });
|
||||
|
||||
const activityId = await makeActivity(owner, "public", "Public Walk");
|
||||
await fanout(activityId);
|
||||
|
||||
expect((await listForUser(a1)).rows.length).toBe(1);
|
||||
expect((await listForUser(a2)).rows.length).toBe(1);
|
||||
expect((await listForUser(p1)).rows.length).toBe(0);
|
||||
expect((await listForUser(p2)).rows.length).toBe(0);
|
||||
|
||||
const a1Rows = (await listForUser(a1)).rows;
|
||||
expect(a1Rows[0]?.type).toBe("activity_published");
|
||||
expect((a1Rows[0]?.payload as { activityName?: string })?.activityName).toBe("Public Walk");
|
||||
});
|
||||
|
||||
it("skips fan-out for non-public activities (defense in depth)", async () => {
|
||||
const owner = await makeUser({ username: `nf_np_o_${Date.now()}` });
|
||||
const f = await makeUser({ username: `nf_np_f_${Date.now()}` });
|
||||
await makeFollow(f, owner, { accepted: true });
|
||||
|
||||
const privateAct = await makeActivity(owner, "private");
|
||||
const unlistedAct = await makeActivity(owner, "unlisted");
|
||||
await fanout(privateAct);
|
||||
await fanout(unlistedAct);
|
||||
|
||||
expect((await listForUser(f)).rows.length).toBe(0);
|
||||
});
|
||||
|
||||
it("is idempotent under retry — second fanout doesn't double-insert", async () => {
|
||||
const owner = await makeUser({ username: `nf_id_o_${Date.now()}` });
|
||||
const f = await makeUser({ username: `nf_id_f_${Date.now()}` });
|
||||
await makeFollow(f, owner, { accepted: true });
|
||||
|
||||
const activityId = await makeActivity(owner, "public");
|
||||
await fanout(activityId);
|
||||
await fanout(activityId);
|
||||
|
||||
expect((await listForUser(f)).rows.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { activities, follows, users } from "@trails-cool/db/schema/journal";
|
||||
import { createNotification } from "../lib/notifications.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Fan out an `activity_published` notification to every accepted
|
||||
* follower of the activity's owner. Idempotent at the DB level via the
|
||||
* `(recipient_user_id, type, subject_id)` unique partial index — a
|
||||
* retry after partial failure won't double-insert.
|
||||
*/
|
||||
export const notificationsFanoutJob = defineJournalJob({
|
||||
name: "notifications-fanout",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 300,
|
||||
async handler(job) {
|
||||
// pg-boss v12: `job` may be an array (batch) per its docs; we
|
||||
// process whichever shape we get.
|
||||
const batch = Array.isArray(job) ? job : [job];
|
||||
for (const item of batch) {
|
||||
await fanout(item.data.activityId);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export async function fanout(activityId: string): Promise<void> {
|
||||
const db = getDb();
|
||||
|
||||
// Load the activity + owner info needed for the payload snapshot.
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: activities.id,
|
||||
name: activities.name,
|
||||
visibility: activities.visibility,
|
||||
ownerId: activities.ownerId,
|
||||
ownerUsername: users.username,
|
||||
ownerDisplayName: users.displayName,
|
||||
})
|
||||
.from(activities)
|
||||
.innerJoin(users, eq(activities.ownerId, users.id))
|
||||
.where(eq(activities.id, activityId));
|
||||
|
||||
if (!row) {
|
||||
logger.warn({ activityId }, "fanout: activity not found, skipping");
|
||||
return;
|
||||
}
|
||||
// Remote-ingested rows have no local owner and never fan out locally
|
||||
// (the users innerJoin already excludes them; this narrows the type).
|
||||
if (row.ownerId === null) return;
|
||||
// Defense in depth — the create-side guard already filters this, but
|
||||
// recheck here so the job doesn't mistakenly fan out a row that was
|
||||
// later flipped to private/unlisted before the job ran.
|
||||
if (row.visibility !== "public") {
|
||||
logger.info({ activityId, visibility: row.visibility }, "fanout: skipping non-public activity");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find every accepted *local* follower of the owner. Remote
|
||||
// followers (follower_id NULL, follower_actor_iri set) get the
|
||||
// activity via federation push delivery, not via notifications.
|
||||
const recipients = await db
|
||||
.select({ followerId: follows.followerId })
|
||||
.from(follows)
|
||||
.where(
|
||||
and(
|
||||
eq(follows.followedUserId, row.ownerId),
|
||||
isNotNull(follows.acceptedAt),
|
||||
isNotNull(follows.followerId),
|
||||
),
|
||||
);
|
||||
|
||||
let inserted = 0;
|
||||
for (const r of recipients) {
|
||||
// Don't notify the owner about their own activity if they happen
|
||||
// to follow themselves (shouldn't happen — followUser refuses
|
||||
// self-follow — but defense in depth).
|
||||
if (r.followerId === row.ownerId || r.followerId === null) continue;
|
||||
const created = await createNotification({
|
||||
type: "activity_published",
|
||||
recipientUserId: r.followerId,
|
||||
actorUserId: row.ownerId,
|
||||
subjectId: row.id,
|
||||
payload: {
|
||||
activityId: row.id,
|
||||
activityName: row.name,
|
||||
ownerUsername: row.ownerUsername,
|
||||
ownerDisplayName: row.ownerDisplayName,
|
||||
},
|
||||
});
|
||||
if (created) inserted += 1;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ activityId, recipients: recipients.length, inserted },
|
||||
"notifications-fanout completed",
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { purgeReadOlderThan } from "../lib/notifications.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Daily retention pass. Drops notifications whose `read_at` is older
|
||||
* than 90 days; unread rows are kept indefinitely so users never miss
|
||||
* an event.
|
||||
*/
|
||||
export const notificationsPurgeJob = defineJournalJob({
|
||||
name: "notifications-purge",
|
||||
cron: "30 3 * * *", // daily at 03:30 UTC (offset from demo-bot-prune to spread load)
|
||||
retryLimit: 1,
|
||||
expireInSeconds: 60,
|
||||
async handler() {
|
||||
const days = 90;
|
||||
const purged = await purgeReadOlderThan(days);
|
||||
logger.info({ days, purged }, "notifications-purge");
|
||||
return { days, purged };
|
||||
},
|
||||
});
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Importing every job module pulls in their (transitively heavy)
|
||||
// server deps; mock the leaf modules with side effects so this stays a
|
||||
// unit test of the registry wiring.
|
||||
import { vi } from "vitest";
|
||||
vi.mock("../lib/db.ts", () => ({ getDb: vi.fn() }));
|
||||
vi.mock("../lib/logger.server.ts", () => ({
|
||||
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
}));
|
||||
|
||||
describe("job registry", () => {
|
||||
it("every job's queue name is unique and a key of JobPayloads", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("./backfill-user-keypairs.ts"),
|
||||
import("./consumed-jti-sweep.ts"),
|
||||
import("./deliver-activity.ts"),
|
||||
import("./demo-bot-generate.ts"),
|
||||
import("./demo-bot-prune.ts"),
|
||||
import("./federation-kv-sweep.ts"),
|
||||
import("./garmin-import-activity.ts"),
|
||||
import("./import-batches-sweep.ts"),
|
||||
import("./komoot-bulk-import.ts"),
|
||||
import("./notifications-fanout.ts"),
|
||||
import("./notifications-purge.ts"),
|
||||
import("./poll-remote-actor.ts"),
|
||||
import("./poll-remote-outboxes.ts"),
|
||||
import("./send-welcome-email.ts"),
|
||||
]);
|
||||
|
||||
const names = modules.flatMap((m) =>
|
||||
Object.values(m as Record<string, unknown>)
|
||||
.filter(
|
||||
(v): v is { name: string; handler: unknown } =>
|
||||
typeof v === "object" && v !== null && "handler" in v && "name" in v,
|
||||
)
|
||||
.map((def) => def.name),
|
||||
);
|
||||
|
||||
expect(names).toHaveLength(14);
|
||||
expect(new Set(names).size).toBe(names.length);
|
||||
// pg-boss v11+ queue-name constraint, mirrored from packages/jobs
|
||||
for (const name of names) {
|
||||
expect(name).toMatch(/^[A-Za-z0-9_.-]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import { defineJob, type JobDefinition, type TypedJobDefinition } from "@trails-cool/jobs";
|
||||
import type { DeliveryPayload } from "../lib/federation-delivery.server.ts";
|
||||
import type { GarminImportData } from "../lib/connected-services/providers/garmin/import.server.ts";
|
||||
|
||||
/**
|
||||
* Every journal job queue and its payload shape, in one place. The
|
||||
* typed `enqueue` / `enqueueOptional` in boss.server.ts and the
|
||||
* `defineJournalJob` helper below both key off this map, so an enqueue
|
||||
* site and its handler cannot drift apart, and a queue-name typo is a
|
||||
* compile error instead of an orphaned queue.
|
||||
*
|
||||
* `void` marks cron-only jobs that are never enqueued with data.
|
||||
*/
|
||||
export interface JobPayloads {
|
||||
"backfill-user-keypairs": Record<string, never>;
|
||||
"consumed-jti-sweep": void;
|
||||
"deliver-activity": DeliveryPayload;
|
||||
"demo-bot-generate": void;
|
||||
"demo-bot-prune": void;
|
||||
"federation-dedup-sweep": void;
|
||||
"federation-kv-sweep": void;
|
||||
"garmin-import-activity": GarminImportData;
|
||||
"import-batches-sweep": void;
|
||||
"komoot-bulk-import": { batchId: string; userId: string; serviceId: string };
|
||||
"notifications-fanout": { activityId: string };
|
||||
"notifications-purge": void;
|
||||
"poll-remote-actor": { actorIri: string };
|
||||
"poll-remote-outboxes": void;
|
||||
"send-welcome-email": { email: string; username: string };
|
||||
"surface-backfill": { kind: "route" | "activity"; id: string };
|
||||
}
|
||||
|
||||
export type JobName = keyof JobPayloads;
|
||||
|
||||
/**
|
||||
* defineJob, constrained to the journal's queue map: the name must be
|
||||
* a known queue and the handler's payload type follows from it.
|
||||
*/
|
||||
export function defineJournalJob<K extends JobName>(
|
||||
definition: TypedJobDefinition<JobPayloads[K]> & { name: K },
|
||||
): JobDefinition {
|
||||
return defineJob<JobPayloads[K]>(definition);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { pollRemoteActor } from "../lib/federation-ingest.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Poll one remote trails actor's outbox (spec §7). Enqueued by the
|
||||
* inbox Accept(Follow) listener (first poll, 7.5) and fanned out by
|
||||
* the poll-remote-outboxes cron sweep (7.1).
|
||||
*/
|
||||
export const pollRemoteActorJob = defineJournalJob({
|
||||
name: "poll-remote-actor",
|
||||
retryLimit: 2,
|
||||
expireInSeconds: 120,
|
||||
async handler(jobs) {
|
||||
for (const job of jobs) {
|
||||
// Defensive: jobs enqueued before the typed seam may carry no data.
|
||||
const actorIri = job.data?.actorIri;
|
||||
if (!actorIri) continue;
|
||||
const result = await pollRemoteActor(actorIri);
|
||||
logger.info({ actorIri, result }, "poll-remote-actor");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { listActorsDuePolling } from "../lib/federation-ingest.server.ts";
|
||||
import { enqueueOptional } from "../lib/boss.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Cron sweep (spec 7.1): every 5 minutes, find remote trails actors
|
||||
* that at least one local user follows (accepted) and that haven't
|
||||
* been polled within the last hour, and fan out one poll-remote-actor
|
||||
* job each. Per-host pacing lives in the poll itself.
|
||||
*/
|
||||
export const pollRemoteOutboxesJob = defineJournalJob({
|
||||
name: "poll-remote-outboxes",
|
||||
cron: "*/5 * * * *",
|
||||
retryLimit: 1,
|
||||
expireInSeconds: 60,
|
||||
async handler() {
|
||||
const due = await listActorsDuePolling();
|
||||
for (const actorIri of due) {
|
||||
await enqueueOptional("poll-remote-actor", { actorIri }, { source: "poll-remote-outboxes" });
|
||||
}
|
||||
if (due.length > 0) logger.info({ due: due.length }, "poll-remote-outboxes sweep");
|
||||
return { due: due.length };
|
||||
},
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { sendWelcome } from "../lib/email.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
/**
|
||||
* Queue-backed welcome email send. The old code did
|
||||
* `sendWelcome(...).catch(log)` inline, which silently dropped failures.
|
||||
* pg-boss retries on transient failure (3 attempts) and surfaces persistent
|
||||
* failures via the dead-letter queue / logs.
|
||||
*/
|
||||
export const sendWelcomeEmailJob = defineJournalJob({
|
||||
name: "send-welcome-email",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 120,
|
||||
async handler(job) {
|
||||
const batch = Array.isArray(job) ? job : [job];
|
||||
for (const item of batch) {
|
||||
const { email, username } = item.data;
|
||||
try {
|
||||
await sendWelcome(email, username);
|
||||
} catch (err) {
|
||||
logger.error({ err, email }, "send-welcome-email job failed");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const execute = vi.fn();
|
||||
vi.mock("../lib/db.ts", () => ({ getDb: () => ({ execute }) }));
|
||||
|
||||
const fetchWaysInBbox = vi.fn();
|
||||
vi.mock("../lib/overpass-ways.server.ts", () => ({ fetchWaysInBbox: (...a: unknown[]) => fetchWaysInBbox(...a) }));
|
||||
|
||||
const emitTo = vi.fn();
|
||||
vi.mock("../lib/events.server.ts", () => ({ emitTo: (...a: unknown[]) => emitTo(...a) }));
|
||||
|
||||
vi.mock("../lib/logger.server.ts", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
import { runSurfaceBackfill } from "./surface-backfill.ts";
|
||||
|
||||
const lineString = (coords: number[][]) => JSON.stringify({ type: "LineString", coordinates: coords });
|
||||
|
||||
beforeEach(() => {
|
||||
execute.mockReset();
|
||||
fetchWaysInBbox.mockReset();
|
||||
emitTo.mockReset();
|
||||
});
|
||||
|
||||
describe("runSurfaceBackfill", () => {
|
||||
it("matches ways, stores the breakdown, and emits to the owner", async () => {
|
||||
execute.mockResolvedValueOnce([
|
||||
{ geojson: lineString([[0, 0], [0.001, 0], [0.002, 0]]), ownerId: "user-1", hasBreakdown: false },
|
||||
]); // SELECT
|
||||
execute.mockResolvedValueOnce(undefined); // UPDATE
|
||||
fetchWaysInBbox.mockResolvedValue([
|
||||
{ highway: "residential", surface: "asphalt", geometry: [{ lat: 0, lon: 0 }, { lat: 0, lon: 0.01 }] },
|
||||
]);
|
||||
|
||||
await runSurfaceBackfill("activity", "act-1");
|
||||
|
||||
expect(fetchWaysInBbox).toHaveBeenCalledOnce();
|
||||
expect(execute).toHaveBeenCalledTimes(2); // SELECT + UPDATE
|
||||
expect(emitTo).toHaveBeenCalledWith("user-1", "surface_breakdown", { kind: "activity", id: "act-1" });
|
||||
});
|
||||
|
||||
it("skips a row that already has a breakdown", async () => {
|
||||
execute.mockResolvedValueOnce([{ geojson: lineString([[0, 0], [1, 0]]), ownerId: "u", hasBreakdown: true }]);
|
||||
await runSurfaceBackfill("route", "r-1");
|
||||
expect(fetchWaysInBbox).not.toHaveBeenCalled();
|
||||
expect(execute).toHaveBeenCalledTimes(1); // only the SELECT
|
||||
expect(emitTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not store or emit when Overpass returns no ways", async () => {
|
||||
execute.mockResolvedValueOnce([{ geojson: lineString([[0, 0], [0.001, 0]]), ownerId: "u", hasBreakdown: false }]);
|
||||
fetchWaysInBbox.mockResolvedValue([]);
|
||||
await runSurfaceBackfill("activity", "a-2");
|
||||
expect(execute).toHaveBeenCalledTimes(1); // SELECT only
|
||||
expect(emitTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { sql } from "drizzle-orm";
|
||||
import { defineJournalJob } from "./payloads.ts";
|
||||
import { getDb } from "../lib/db.ts";
|
||||
import { computeSurfaceBreakdown } from "@trails-cool/map-core";
|
||||
import { fetchWaysInBbox, type Bbox } from "../lib/overpass-ways.server.ts";
|
||||
import { matchSurfaces } from "../lib/surface-match.server.ts";
|
||||
import { emitTo } from "../lib/events.server.ts";
|
||||
import { logger } from "../lib/logger.server.ts";
|
||||
|
||||
// Cap coordinates fed to the matcher; the breakdown still weights by the actual
|
||||
// segment length, so downsampling barely shifts proportions.
|
||||
const MAX_COORDS = 250;
|
||||
const BBOX_PAD_DEG = 0.0008; // ~80 m, so ways just off the line are considered
|
||||
|
||||
/**
|
||||
* Derive a surface/waytype breakdown for a route/activity that has geometry but
|
||||
* no breakdown yet (imports, uploads, pre-existing rows), by map-matching its
|
||||
* geometry to OSM ways via Overpass. Idempotent (skips if already set),
|
||||
* best-effort (Overpass failure throws → pg-boss retries; an empty/oversized
|
||||
* bbox is a no-op). On success it stores the breakdown and pushes an SSE event
|
||||
* to the owner so an open detail page fills the bars in live.
|
||||
*/
|
||||
export const surfaceBackfillJob = defineJournalJob({
|
||||
name: "surface-backfill",
|
||||
retryLimit: 3,
|
||||
expireInSeconds: 120,
|
||||
async handler(job) {
|
||||
const batch = Array.isArray(job) ? job : [job];
|
||||
for (const item of batch) {
|
||||
await runSurfaceBackfill(item.data.kind, item.data.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export async function runSurfaceBackfill(kind: "route" | "activity", id: string): Promise<void> {
|
||||
const db = getDb();
|
||||
const tableName = kind === "route" ? sql`journal.routes` : sql`journal.activities`;
|
||||
|
||||
const loaded = (await db.execute(sql`
|
||||
SELECT ST_AsGeoJSON(geom) AS geojson,
|
||||
owner_id AS "ownerId",
|
||||
(surface_breakdown IS NOT NULL) AS "hasBreakdown"
|
||||
FROM ${tableName} WHERE id = ${id} LIMIT 1
|
||||
`)) as unknown as Array<{ geojson: string | null; ownerId: string | null; hasBreakdown: boolean }>;
|
||||
const row = loaded[0];
|
||||
|
||||
if (!row) {
|
||||
logger.warn({ kind, id }, "surface-backfill: row not found");
|
||||
return;
|
||||
}
|
||||
if (row.hasBreakdown) return; // idempotent
|
||||
if (!row.geojson) return; // no geometry to match
|
||||
|
||||
let coords: number[][];
|
||||
try {
|
||||
coords = (JSON.parse(row.geojson) as { coordinates?: number[][] }).coordinates ?? [];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (coords.length < 2) return;
|
||||
|
||||
if (coords.length > MAX_COORDS) {
|
||||
const stride = Math.ceil(coords.length / MAX_COORDS);
|
||||
const out: number[][] = [];
|
||||
for (let i = 0; i < coords.length; i += stride) out.push(coords[i]!);
|
||||
const last = coords[coords.length - 1]!;
|
||||
if (out[out.length - 1] !== last) out.push(last);
|
||||
coords = out;
|
||||
}
|
||||
|
||||
let south = coords[0]![1]!, north = south, west = coords[0]![0]!, east = west;
|
||||
for (const c of coords) {
|
||||
const lon = c[0]!, lat = c[1]!;
|
||||
if (lat < south) south = lat;
|
||||
if (lat > north) north = lat;
|
||||
if (lon < west) west = lon;
|
||||
if (lon > east) east = lon;
|
||||
}
|
||||
const bbox: Bbox = {
|
||||
south: south - BBOX_PAD_DEG,
|
||||
west: west - BBOX_PAD_DEG,
|
||||
north: north + BBOX_PAD_DEG,
|
||||
east: east + BBOX_PAD_DEG,
|
||||
};
|
||||
|
||||
const ways = await fetchWaysInBbox(bbox);
|
||||
if (ways.length === 0) {
|
||||
logger.info({ kind, id }, "surface-backfill: no ways (bbox empty/oversized) — skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const { surfaces, highways } = matchSurfaces(coords, ways);
|
||||
const breakdown = computeSurfaceBreakdown(coords, surfaces, highways);
|
||||
if (Object.keys(breakdown.surface).length === 0 && Object.keys(breakdown.highway).length === 0) return;
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE ${tableName} SET surface_breakdown = ${JSON.stringify(breakdown)}::jsonb WHERE id = ${id}
|
||||
`);
|
||||
if (row.ownerId) emitTo(row.ownerId, "surface_breakdown", { kind, id });
|
||||
logger.info({ kind, id }, "surface-backfill: stored breakdown");
|
||||
}
|
||||
|
|
@ -1,134 +1,76 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { eq, desc, and, isNotNull, inArray, sql } from "drizzle-orm";
|
||||
import { unionAll } from "drizzle-orm/pg-core";
|
||||
import { eq, desc, and, sql } from "drizzle-orm";
|
||||
import { getDb } from "./db.ts";
|
||||
import { activities, routes, syncImports, users, follows, remoteActors } from "@trails-cool/db/schema/journal";
|
||||
import type { Visibility, SportType } from "@trails-cool/db/schema/journal";
|
||||
import { processGpx, writeGeom } from "./gpx-save.server.ts";
|
||||
import type { ProcessedGpx } from "./gpx-save.server.ts";
|
||||
import { enqueueOptional } from "./boss.server.ts";
|
||||
import type { OwnedRef } from "./ownership.server.ts";
|
||||
import {
|
||||
enqueueActivityDeliveries,
|
||||
visibilityTransitionAction,
|
||||
} from "./federation-delivery.server.ts";
|
||||
import { activities, routes, syncImports, users, follows } from "@trails-cool/db/schema/journal";
|
||||
import type { Visibility } from "@trails-cool/db/schema/journal";
|
||||
import { parseGpxAsync } from "@trails-cool/gpx";
|
||||
import { setGeomFromGpx } from "./routes.server.ts";
|
||||
|
||||
export interface ActivityInput {
|
||||
name: string;
|
||||
description?: string;
|
||||
sportType?: SportType | null;
|
||||
gpx?: string;
|
||||
routeId?: string;
|
||||
distance?: number | null;
|
||||
duration?: number | null;
|
||||
startedAt?: Date | null;
|
||||
visibility?: Visibility;
|
||||
synthetic?: boolean;
|
||||
}
|
||||
|
||||
export async function updateActivityVisibility(
|
||||
ownedActivity: OwnedRef,
|
||||
id: string,
|
||||
ownerId: string,
|
||||
visibility: Visibility,
|
||||
): Promise<boolean> {
|
||||
const { id, ownerId } = ownedActivity;
|
||||
const db = getDb();
|
||||
// Read the previous visibility first: the federation action depends on
|
||||
// the *transition*, not the new value alone (a gratuitous Delete
|
||||
// permanently tombstones the object's URI on Mastodon — see
|
||||
// visibilityTransitionAction).
|
||||
const [existing] = await db
|
||||
.select({ visibility: activities.visibility })
|
||||
.from(activities)
|
||||
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
|
||||
.limit(1);
|
||||
if (!existing) return false;
|
||||
|
||||
await db
|
||||
const result = await db
|
||||
.update(activities)
|
||||
.set({ visibility })
|
||||
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
|
||||
|
||||
// Notify followers when an activity becomes public. The unique
|
||||
// (recipient, type, subject_id) partial index makes the fan-out
|
||||
// idempotent, so toggling private→public→private→public won't spam
|
||||
// followers (only the first transition per activity emits).
|
||||
if (visibility === "public") {
|
||||
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "updateActivityVisibility" });
|
||||
}
|
||||
|
||||
// Federation: Create on (re-)publish, Delete(Tombstone) only when the
|
||||
// activity actually was public before — remotes never saw anything
|
||||
// else, and an unnecessary Delete poisons the URI forever.
|
||||
const action = visibilityTransitionAction(existing.visibility, visibility);
|
||||
if (action !== null) {
|
||||
await enqueueActivityDeliveries(ownerId, id, action);
|
||||
}
|
||||
|
||||
return true;
|
||||
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)))
|
||||
.returning({ id: activities.id });
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
export async function createActivity(ownerId: string, input: ActivityInput) {
|
||||
const db = getDb();
|
||||
const id = randomUUID();
|
||||
|
||||
let processed: ProcessedGpx | null = null;
|
||||
let distance: number | null = input.distance ?? null;
|
||||
let elevationGain: number | null = null;
|
||||
let elevationLoss: number | null = null;
|
||||
let startedAt: Date | null = input.startedAt ?? null;
|
||||
const duration: number | null = input.duration ?? null;
|
||||
|
||||
if (input.gpx) {
|
||||
processed = await processGpx(input.gpx);
|
||||
// GPX-derived distance wins unless it is zero; caller input is the fallback
|
||||
distance = processed.stats.distance || distance;
|
||||
elevationGain = processed.stats.elevationGain;
|
||||
elevationLoss = processed.stats.elevationLoss;
|
||||
startedAt = startedAt ?? processed.stats.startTime;
|
||||
try {
|
||||
const gpxData = await parseGpxAsync(input.gpx);
|
||||
distance = gpxData.distance || distance;
|
||||
elevationGain = gpxData.elevation.gain;
|
||||
elevationLoss = gpxData.elevation.loss;
|
||||
|
||||
if (!startedAt && gpxData.tracks[0]?.[0]?.time) {
|
||||
startedAt = new Date(gpxData.tracks[0][0].time);
|
||||
}
|
||||
} catch {
|
||||
// Continue without stats if GPX parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(activities).values({
|
||||
id,
|
||||
ownerId,
|
||||
routeId: input.routeId ?? null,
|
||||
name: input.name,
|
||||
description: input.description ?? "",
|
||||
sportType: input.sportType ?? null,
|
||||
gpx: input.gpx,
|
||||
distance,
|
||||
duration,
|
||||
elevationGain,
|
||||
elevationLoss,
|
||||
startedAt,
|
||||
...(input.visibility ? { visibility: input.visibility } : {}),
|
||||
...(input.synthetic ? { synthetic: true } : {}),
|
||||
});
|
||||
|
||||
if (input.gpx && processed) {
|
||||
await writeGeom(tx, id, "activities", processed.coords);
|
||||
}
|
||||
await db.insert(activities).values({
|
||||
id,
|
||||
ownerId,
|
||||
routeId: input.routeId ?? null,
|
||||
name: input.name,
|
||||
description: input.description ?? "",
|
||||
gpx: input.gpx,
|
||||
distance,
|
||||
duration,
|
||||
elevationGain,
|
||||
elevationLoss,
|
||||
startedAt,
|
||||
});
|
||||
|
||||
// Public activities at creation also fan out (matches the
|
||||
// updateActivityVisibility path for the case where visibility is set
|
||||
// up-front rather than flipped later).
|
||||
if (input.visibility === "public") {
|
||||
await enqueueOptional("notifications-fanout", { activityId: id }, { source: "createActivity" });
|
||||
// Federation push delivery to accepted remote followers (spec 5.3).
|
||||
await enqueueActivityDeliveries(ownerId, id, "create");
|
||||
}
|
||||
|
||||
// Activities arrive without surface waytags (bare GPX); kick off the async
|
||||
// Overpass backfill (route-surface-breakdown Path 2). Skipped for synthetic
|
||||
// demo content. Best-effort — never blocks creation.
|
||||
if (input.gpx && !input.synthetic) {
|
||||
await enqueueOptional(
|
||||
"surface-backfill",
|
||||
{ kind: "activity", id },
|
||||
{ source: "createActivity" },
|
||||
{ singletonKey: `surface:activity:${id}` },
|
||||
);
|
||||
if (input.gpx) {
|
||||
await setGeomFromGpx(id, "activities", input.gpx);
|
||||
}
|
||||
|
||||
return id;
|
||||
|
|
@ -143,20 +85,11 @@ export async function getActivity(id: string) {
|
|||
return { ...activity, geojson, importSource };
|
||||
}
|
||||
|
||||
export async function deleteActivity(ownedActivity: OwnedRef): Promise<boolean> {
|
||||
const { id, ownerId } = ownedActivity;
|
||||
export async function deleteActivity(id: string, ownerId: string): Promise<boolean> {
|
||||
const db = getDb();
|
||||
// The WHERE ownerId clause stays as defense in depth even though the
|
||||
// OwnedRef brand already proves ownership.
|
||||
const [activity] = await db.select({ id: activities.id, visibility: activities.visibility }).from(activities)
|
||||
const [activity] = await db.select({ id: activities.id }).from(activities)
|
||||
.where(and(eq(activities.id, id), eq(activities.ownerId, ownerId)));
|
||||
if (!activity) return false;
|
||||
// Enqueue the federation retraction *before* the row disappears —
|
||||
// the Delete payload carries everything it needs (object IRI +
|
||||
// owner), so it survives the deletion (spec 5.6).
|
||||
if (activity.visibility === "public") {
|
||||
await enqueueActivityDeliveries(ownerId, id, "delete");
|
||||
}
|
||||
await db.delete(activities).where(eq(activities.id, id));
|
||||
return true;
|
||||
}
|
||||
|
|
@ -170,109 +103,13 @@ async function getImportSource(activityId: string): Promise<{ provider: string;
|
|||
return row ?? null;
|
||||
}
|
||||
|
||||
export interface ActivityStats {
|
||||
count: number;
|
||||
/** metres */
|
||||
distance: number;
|
||||
/** metres */
|
||||
elevationGain: number;
|
||||
/** seconds, elapsed */
|
||||
duration: number;
|
||||
/** activities started in the last 28 days */
|
||||
last4Weeks: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate roll-up for a profile. One indexed aggregate over stored columns
|
||||
* (no GPX parsing) — `owner_id` leads two existing indexes, so this is cheap
|
||||
* even for power users (see profile-stats design §D1). `publicOnly` scopes the
|
||||
* roll-up to what a visitor may see; the owner passes `false` for full totals.
|
||||
*/
|
||||
export async function getActivityStats(
|
||||
ownerId: string,
|
||||
opts: { publicOnly: boolean },
|
||||
): Promise<ActivityStats> {
|
||||
export async function listActivities(ownerId: string) {
|
||||
const db = getDb();
|
||||
const conditions = [eq(activities.ownerId, ownerId)];
|
||||
if (opts.publicOnly) conditions.push(eq(activities.visibility, "public"));
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
count: sql<string>`count(*)`,
|
||||
distance: sql<string>`coalesce(sum(${activities.distance}), 0)`,
|
||||
elevationGain: sql<string>`coalesce(sum(${activities.elevationGain}), 0)`,
|
||||
duration: sql<string>`coalesce(sum(${activities.duration}), 0)`,
|
||||
last4Weeks: sql<string>`count(*) filter (where coalesce(${activities.startedAt}, ${activities.createdAt}) >= now() - interval '28 days')`,
|
||||
})
|
||||
.from(activities)
|
||||
.where(and(...conditions));
|
||||
|
||||
// Postgres returns count/sum as strings via the driver; coerce to numbers.
|
||||
return {
|
||||
count: Number(row?.count ?? 0),
|
||||
distance: Number(row?.distance ?? 0),
|
||||
elevationGain: Number(row?.elevationGain ?? 0),
|
||||
duration: Number(row?.duration ?? 0),
|
||||
last4Weeks: Number(row?.last4Weeks ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
export interface WeeklyDistanceBucket {
|
||||
/** ISO date (YYYY-MM-DD) of the week's Monday */
|
||||
weekStart: string;
|
||||
/** metres */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distance per week for the last `weeks` weeks (oldest → newest), gap-filled so
|
||||
* every week is present (zero when no activity). The contiguous axis is built
|
||||
* in SQL via `generate_series` + a LEFT JOIN — that guarantees the week
|
||||
* boundaries match Postgres `date_trunc('week', …)` exactly (no JS/Postgres
|
||||
* boundary drift) and keeps it one cheap query over the owner_id index
|
||||
* (profile-weekly-distance design §D1–D2). `publicOnly` scopes it like
|
||||
* `getActivityStats`.
|
||||
*/
|
||||
export async function getWeeklyDistance(
|
||||
ownerId: string,
|
||||
opts: { publicOnly: boolean; weeks?: number },
|
||||
): Promise<WeeklyDistanceBucket[]> {
|
||||
const weeks = opts.weeks ?? 12;
|
||||
const db = getDb();
|
||||
// Filter conditions live in the LEFT JOIN's ON clause so empty weeks survive.
|
||||
const visibility = opts.publicOnly ? sql` AND a.visibility = 'public'` : sql``;
|
||||
const result = await db.execute(sql`
|
||||
WITH weeks AS (
|
||||
SELECT generate_series(
|
||||
date_trunc('week', now()) - (${weeks - 1} * interval '1 week'),
|
||||
date_trunc('week', now()),
|
||||
interval '1 week'
|
||||
) AS wk
|
||||
)
|
||||
SELECT to_char(w.wk, 'YYYY-MM-DD') AS week_start,
|
||||
coalesce(sum(a.distance), 0) AS distance
|
||||
FROM weeks w
|
||||
LEFT JOIN journal.activities a
|
||||
ON date_trunc('week', coalesce(a.started_at, a.created_at)) = w.wk
|
||||
AND a.owner_id = ${ownerId}${visibility}
|
||||
GROUP BY w.wk
|
||||
ORDER BY w.wk
|
||||
`);
|
||||
const rows = result as unknown as Array<{ week_start: string; distance: string | number }>;
|
||||
return rows.map((r) => ({ weekStart: r.week_start, distance: Number(r.distance) }));
|
||||
}
|
||||
|
||||
export async function listActivities(
|
||||
ownerId: string,
|
||||
sort: "startedAt" | "addedAt" = "startedAt",
|
||||
) {
|
||||
const db = getDb();
|
||||
const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.ownerId, ownerId))
|
||||
.orderBy(order);
|
||||
.orderBy(desc(activities.createdAt));
|
||||
|
||||
const ids = rows.map((r) => r.id);
|
||||
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
|
||||
|
|
@ -284,19 +121,13 @@ export async function listActivities(
|
|||
* listings (the public profile page); never includes `unlisted` or
|
||||
* `private` content.
|
||||
*/
|
||||
export async function listPublicActivitiesForOwner(
|
||||
ownerId: string,
|
||||
sort: "startedAt" | "addedAt" = "startedAt",
|
||||
limit: number = 100,
|
||||
) {
|
||||
export async function listPublicActivitiesForOwner(ownerId: string) {
|
||||
const db = getDb();
|
||||
const order = sort === "addedAt" ? desc(activities.createdAt) : desc(activities.startedAt);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(and(eq(activities.ownerId, ownerId), eq(activities.visibility, "public")))
|
||||
.orderBy(order)
|
||||
.limit(limit);
|
||||
.orderBy(desc(activities.createdAt));
|
||||
|
||||
const ids = rows.map((r) => r.id);
|
||||
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
|
||||
|
|
@ -304,40 +135,24 @@ export async function listPublicActivitiesForOwner(
|
|||
}
|
||||
|
||||
/**
|
||||
* Social feed (spec: social-federation §8): aggregated activities from
|
||||
* actors that `followerId` follows with an *accepted* follow — local
|
||||
* users and remote trails actors alike. Reverse-chronological on
|
||||
* COALESCE(remote_published_at, created_at).
|
||||
*
|
||||
* Audience rules:
|
||||
* - local rows: `visibility = 'public'` only (unlisted/private never
|
||||
* appear regardless of follow state)
|
||||
* - remote rows: `audience = 'public'` or `followers-only` — the
|
||||
* latter gated structurally by joining the *viewer's own* accepted
|
||||
* follow against the originating actor (spec: "Followers-only remote
|
||||
* content reaches only the right viewer")
|
||||
* Pending follows contribute nothing (accepted_at IS NOT NULL on both
|
||||
* branches — previously missing on the local branch).
|
||||
* Social feed: aggregated public activities from users that `followerId`
|
||||
* follows (accepted only). Reverse-chronological. Joins users for owner
|
||||
* attribution. Unlisted/private activities never appear, regardless of
|
||||
* follow state.
|
||||
*/
|
||||
export async function listSocialFeed(followerId: string, limit: number = 50) {
|
||||
const db = getDb();
|
||||
|
||||
const local = db
|
||||
const rows = await db
|
||||
.select({
|
||||
id: activities.id,
|
||||
name: activities.name,
|
||||
sportType: activities.sportType,
|
||||
distance: activities.distance,
|
||||
elevationGain: activities.elevationGain,
|
||||
duration: activities.duration,
|
||||
startedAt: activities.startedAt,
|
||||
createdAt: activities.createdAt,
|
||||
sortTime: sql<Date>`${activities.createdAt}`.as("sort_time"),
|
||||
ownerUsername: sql<string | null>`${users.username}`.as("owner_username"),
|
||||
ownerDisplayName: sql<string | null>`${users.displayName}`.as("owner_display_name"),
|
||||
ownerDomain: sql<string | null>`${users.domain}`.as("owner_domain"),
|
||||
externalUrl: sql<string | null>`NULL`.as("external_url"),
|
||||
remote: sql<boolean>`false`.as("remote"),
|
||||
ownerUsername: users.username,
|
||||
ownerDisplayName: users.displayName,
|
||||
})
|
||||
.from(activities)
|
||||
.innerJoin(follows, eq(follows.followedUserId, activities.ownerId))
|
||||
|
|
@ -345,47 +160,14 @@ export async function listSocialFeed(followerId: string, limit: number = 50) {
|
|||
.where(
|
||||
and(
|
||||
eq(follows.followerId, followerId),
|
||||
isNotNull(follows.acceptedAt),
|
||||
eq(activities.visibility, "public"),
|
||||
),
|
||||
);
|
||||
|
||||
const remote = db
|
||||
.select({
|
||||
id: activities.id,
|
||||
name: activities.name,
|
||||
sportType: activities.sportType,
|
||||
distance: activities.distance,
|
||||
elevationGain: activities.elevationGain,
|
||||
duration: activities.duration,
|
||||
startedAt: activities.startedAt,
|
||||
createdAt: activities.createdAt,
|
||||
sortTime: sql<Date>`COALESCE(${activities.remotePublishedAt}, ${activities.createdAt})`.as("sort_time"),
|
||||
ownerUsername: sql<string | null>`${remoteActors.username}`.as("owner_username"),
|
||||
ownerDisplayName: sql<string | null>`${remoteActors.displayName}`.as("owner_display_name"),
|
||||
ownerDomain: sql<string | null>`${remoteActors.domain}`.as("owner_domain"),
|
||||
externalUrl: sql<string | null>`${activities.remoteOriginIri}`.as("external_url"),
|
||||
remote: sql<boolean>`true`.as("remote"),
|
||||
})
|
||||
.from(activities)
|
||||
.innerJoin(follows, eq(follows.followedActorIri, activities.remoteActorIri))
|
||||
.leftJoin(remoteActors, eq(activities.remoteActorIri, remoteActors.actorIri))
|
||||
.where(
|
||||
and(
|
||||
eq(follows.followerId, followerId),
|
||||
isNotNull(follows.acceptedAt),
|
||||
// public reaches every accepted follower; followers-only is
|
||||
// already gated by joining the viewer's own accepted follow.
|
||||
sql`${activities.audience} IN ('public', 'followers-only')`,
|
||||
),
|
||||
);
|
||||
|
||||
const rows = await unionAll(local, remote)
|
||||
.orderBy(sql`sort_time DESC`)
|
||||
)
|
||||
.orderBy(desc(activities.createdAt))
|
||||
.limit(limit);
|
||||
|
||||
const localIds = rows.filter((r) => !r.remote).map((r) => r.id);
|
||||
const geojsonMap = localIds.length > 0 ? await getSimplifiedActivityGeojsonBatch(localIds) : new Map();
|
||||
const ids = rows.map((r) => r.id);
|
||||
const geojsonMap = ids.length > 0 ? await getSimplifiedActivityGeojsonBatch(ids) : new Map();
|
||||
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
|
||||
}
|
||||
|
||||
|
|
@ -402,7 +184,6 @@ export async function listRecentPublicActivities(limit: number = 20) {
|
|||
.select({
|
||||
id: activities.id,
|
||||
name: activities.name,
|
||||
sportType: activities.sportType,
|
||||
distance: activities.distance,
|
||||
elevationGain: activities.elevationGain,
|
||||
duration: activities.duration,
|
||||
|
|
@ -422,43 +203,39 @@ export async function listRecentPublicActivities(limit: number = 20) {
|
|||
return rows.map((r) => ({ ...r, geojson: geojsonMap.get(r.id) ?? null }));
|
||||
}
|
||||
|
||||
export async function linkActivityToRoute(ownedActivity: OwnedRef, ownedRoute: OwnedRef) {
|
||||
export async function linkActivityToRoute(activityId: string, routeId: string, _ownerId: string) {
|
||||
const db = getDb();
|
||||
await db
|
||||
.update(activities)
|
||||
.set({ routeId: ownedRoute.id })
|
||||
.where(and(eq(activities.id, ownedActivity.id), eq(activities.ownerId, ownedActivity.ownerId)));
|
||||
.set({ routeId })
|
||||
.where(eq(activities.id, activityId));
|
||||
}
|
||||
|
||||
export async function createRouteFromActivity(ownedActivity: OwnedRef): Promise<string | null> {
|
||||
const { id: activityId, ownerId } = ownedActivity;
|
||||
export async function createRouteFromActivity(activityId: string, ownerId: string): Promise<string | null> {
|
||||
const db = getDb();
|
||||
const [activity] = await db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(and(eq(activities.id, activityId), eq(activities.ownerId, ownerId)));
|
||||
const [activity] = await db.select().from(activities).where(eq(activities.id, activityId));
|
||||
if (!activity?.gpx) return null;
|
||||
|
||||
const { coords } = await processGpx(activity.gpx);
|
||||
const routeId = randomUUID();
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(routes).values({
|
||||
id: routeId,
|
||||
ownerId,
|
||||
name: `Route from: ${activity.name}`,
|
||||
description: `Created from activity "${activity.name}"`,
|
||||
gpx: activity.gpx,
|
||||
distance: activity.distance,
|
||||
elevationGain: activity.elevationGain,
|
||||
elevationLoss: activity.elevationLoss,
|
||||
});
|
||||
|
||||
await writeGeom(tx, routeId, "routes", coords);
|
||||
|
||||
await tx.update(activities).set({ routeId }).where(eq(activities.id, activityId));
|
||||
await db.insert(routes).values({
|
||||
id: routeId,
|
||||
ownerId,
|
||||
name: `Route from: ${activity.name}`,
|
||||
description: `Created from activity "${activity.name}"`,
|
||||
gpx: activity.gpx,
|
||||
distance: activity.distance,
|
||||
elevationGain: activity.elevationGain,
|
||||
elevationLoss: activity.elevationLoss,
|
||||
});
|
||||
|
||||
await setGeomFromGpx(routeId, "routes", activity.gpx);
|
||||
|
||||
// Link the activity to the new route
|
||||
await db
|
||||
.update(activities)
|
||||
.set({ routeId })
|
||||
.where(eq(activities.id, activityId));
|
||||
|
||||
return routeId;
|
||||
}
|
||||
|
||||
|
|
@ -480,19 +257,13 @@ async function getSimplifiedActivityGeojsonBatch(ids: string[]): Promise<Map<str
|
|||
if (ids.length === 0) return map;
|
||||
try {
|
||||
const db = getDb();
|
||||
// Use the query builder for the id list: a raw `ANY(${ids}::text[])`
|
||||
// makes drizzle expand the array to `($1,$2,...)`, yielding the invalid
|
||||
// `ANY((...)::text[])` — which throws and silently drops every preview.
|
||||
const rows = await db
|
||||
.select({
|
||||
id: activities.id,
|
||||
geojson: sql<string | null>`ST_AsGeoJSON(ST_Simplify(${activities.geom}, 0.001))`,
|
||||
})
|
||||
.from(activities)
|
||||
.where(and(inArray(activities.id, ids), isNotNull(activities.geom)));
|
||||
for (const row of rows) {
|
||||
if (row.geojson) map.set(row.id, row.geojson);
|
||||
}
|
||||
await Promise.all(ids.map(async (id) => {
|
||||
const result = await db.execute(
|
||||
sql`SELECT ST_AsGeoJSON(ST_Simplify(geom, 0.001)) as geojson FROM journal.activities WHERE id = ${id} AND geom IS NOT NULL`,
|
||||
);
|
||||
const row = (result as unknown as Array<{ geojson: string }>)[0];
|
||||
if (row?.geojson) map.set(id, row.geojson);
|
||||
}));
|
||||
} catch {
|
||||
// Fallback: no geojson
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { getOrigin } from "./config.server.ts";
|
||||
|
||||
// Canonical ActivityPub actor IRI for a local user. Used as the key in
|
||||
// `follows.followed_actor_iri` so the column shape is identical for local
|
||||
// and (future) federated follows.
|
||||
// and (future) federated follows. Reading from `process.env.ORIGIN` keeps
|
||||
// us aligned with the rest of the auth/federation stack.
|
||||
export function localActorIri(username: string): string {
|
||||
return `${getOrigin()}/users/${username}`;
|
||||
const origin = process.env.ORIGIN ?? "http://localhost:3000";
|
||||
return `${origin}/users/${username}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { TERMS_VERSION } from "./legal";
|
||||
|
||||
const mockGetAuthenticatedUser = vi.fn();
|
||||
|
||||
vi.mock("./oauth.server.ts", () => ({
|
||||
getAuthenticatedUser: mockGetAuthenticatedUser,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("requireApiUser", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
mockGetAuthenticatedUser.mockResolvedValue(null);
|
||||
const { requireApiUser } = await import("./api-guard.server.ts");
|
||||
|
||||
try {
|
||||
await requireApiUser(new Request("http://localhost/api/v1/routes"));
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(Response);
|
||||
expect((err as Response).status).toBe(401);
|
||||
const body = await (err as Response).json();
|
||||
expect(body.code).toBe("UNAUTHORIZED");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the user when termsVersion matches", async () => {
|
||||
const user = { id: "u1", termsVersion: TERMS_VERSION };
|
||||
mockGetAuthenticatedUser.mockResolvedValue(user);
|
||||
const { requireApiUser } = await import("./api-guard.server.ts");
|
||||
|
||||
const result = await requireApiUser(new Request("http://localhost/api/v1/routes"));
|
||||
expect(result).toBe(user);
|
||||
});
|
||||
|
||||
it("returns 403 TERMS_OUTDATED when termsVersion is stale", async () => {
|
||||
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: "2020-01-01" });
|
||||
const { requireApiUser } = await import("./api-guard.server.ts");
|
||||
|
||||
try {
|
||||
await requireApiUser(new Request("http://localhost/api/v1/routes"));
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(Response);
|
||||
expect((err as Response).status).toBe(403);
|
||||
const body = await (err as Response).json();
|
||||
expect(body.code).toBe("TERMS_OUTDATED");
|
||||
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 403 TERMS_OUTDATED when termsVersion is null", async () => {
|
||||
mockGetAuthenticatedUser.mockResolvedValue({ id: "u1", termsVersion: null });
|
||||
const { requireApiUser } = await import("./api-guard.server.ts");
|
||||
|
||||
try {
|
||||
await requireApiUser(new Request("http://localhost/api/v1/routes"));
|
||||
expect.fail("should throw");
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(Response);
|
||||
expect((err as Response).status).toBe(403);
|
||||
const body = await (err as Response).json();
|
||||
expect(body.code).toBe("TERMS_OUTDATED");
|
||||
expect(body.currentTermsVersion).toBe(TERMS_VERSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue