The Create Invitation flow rendered "/invite?t=…" instead of
"https://host/invite?t=…" because the origin was gated on an unset
PUBLIC_ORIGIN env var.
Solution: OpContext now carries `origin` (always set by the route
handler from Astro.url.origin), and invitations.ts builds the magic
link from it. No env vars required.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Backstage rebuild is complete. The old single-page /admin (with
seven ?tab= sections backed by six tab partials) is gone. /admin
now redirects to the first registered resource (dispatches), and
every entity is served by the shared /admin/[resource] dynamic
route from steps 4–10.
- tests/admin-resources.test.ts: vitest-based verifier that walks
every registered resource and asserts:
- identity fields (key/label/plural/singular/groupKey)
- list.queryFn, action.handler, ops.* members are functions
- column kinds are in the registered set (text/pill/relative-date/
number/tag-list); same for columnsByFilter overrides
- field kinds are in the registered set (11 kinds)
- embed.component is in the registered set (pulse-sub-form)
- resource keys are unique, action keys are unique per resource
- at most one filter is isDefault
- groupKey resolves to a real group
- review-mode resources have at least one action
- ops.create requires a non-null form
87 assertions, integrated into pnpm test, fails CI on any drift.
- src/pages/admin/index.astro: thin redirect to /admin/<first-key>.
- src/pages/admin/preview.astro: deleted (step-4 smoke route).
- src/components/admin/*.astro: deleted (6 old tab partials —
ActivityTab, DispatchesTab, EventsTab, PulsesTab, RoadmapTab,
UserEditTab — all replaced by the resource configs).
Full suite: 147 tests pass (60 prior + 87 verifier). Typecheck
clean. Build clean. Manual smoke shows every /admin/<resource>
URL resolves through the dynamic route; old /admin?tab=… references
exist only in deleted files.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three more resources land. /admin/users replaces the old participants
tab, /admin/invitations replaces the old invites tab, /admin/join_requests
replaces the read-only join queue.
- src/admin/resources/users.ts ("People"): single resource for all users,
filter chips swap visible columns (council shows member_number +
focus_tags; pilots/team show role + last_seen_at). Form fields are
conditional — title / pull_quote / focus_tags / cab_joined_date /
member_number render only when role === cab. No ops.create (users
come via invites); deactivateUser is the delete handler.
- src/admin/resources/invitations.ts: form-for-create, summary-for-view.
Create generates a token via generateInviteToken(), stores its hash,
surfaces the magic link as a one-shot ?invite_url= block in the panel.
Revoke is an action (sets expires_at = now); the row stays for audit.
- src/admin/resources/join-requests.ts: form: null, review-mode panel
with the user's summary + approve_as_cab / decline actions.
Plumbing to support the above:
- src/admin/resource-types.ts: new Resource.summary callback (read-only
field pairs for review panels); OpContext.result lets ops surface
ActionResults (e.g. invite-link).
- src/admin/components/ResourceEditPanel.astro: review mode when an
existing item is shown and resource.summary is defined; renders the
?invite_url= block above the summary with a copy-to-clipboard button.
- src/admin/components/ResourceListView.astro: "+ New" suppressed when
ops.create is undefined.
- src/pages/admin/[resource].astro: captures ctx.result and action
handler return values, propagates them via &invite_url=...; routes to
the list view (not the row) when an action removes the item.
- src/lib/db.ts: adds getJoinRequestById, deleteJoinRequest,
getInviteById.
Deviation from the original delta: no approve_as_pilot action and no
invite-link result on join-request approval. The existing
join_requests schema only stores user_id — requests come from
already-authenticated pilots asking for a CAB upgrade, not from
strangers needing an invite. The schema change for stranger sign-ups
is left for a future follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Backstage rebuild's first three resource configs. /admin/dispatches,
/admin/roadmap, and /admin/events now resolve through the dynamic route
with full list views, edit panels, and the publish/archive actions.
- src/admin/resources/dispatches.ts — kind/status/author/excerpt/body
fields, embedded pulse sub-form (pulse_question + multi-text options +
opens/closes datetimes), publish/archive actions, notifyCount on
drafts so the sidebar lights up terracotta until they ship.
- src/admin/resources/roadmap.ts — title/description/status/target/
display_order/metadata_text plus a multi-select-async for attributed
members. ops.update writes via setRoadmapAttributions() after the
basic save so the pivot table stays in sync.
- src/admin/resources/events.ts — full event fields; ops.create
auto-generates a unique slug from the title when blank.
- src/admin/embeds/PulseSubForm.astro — reads the dispatch's current
pulse via getPulseById(), renders question + options + opens/closes.
Pulses follow their parent dispatch's lifecycle (draft → open on
publish, → closed on archive); no status field of their own.
- src/admin/components/ResourceEditPanel.astro — dispatches on
embed.component, renders PulseSubForm for 'pulse-sub-form'.
- src/admin/resource-types.ts — renamed column .valueOf to .value
(collision with Object.prototype.valueOf was breaking TS structural
matching); OpContext now optionally carries the raw FormData so
resources with sub-forms can read embed fields.
- src/pages/admin/[resource].astro — passes formData into opCtx.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The production route every Backstage resource lives under. Resolves
the resource from the URL segment against the registry, gates on
user.role === 'fenja', and renders the AdminLayout shell with the
ResourceListView + (optionally) ResourceEditPanel.
POST dispatch keyed by _action:
- save: parses formdata per field.kind (multi-text/multi-select-async
use getAll(), number coerces, others coerce to string), validates
via validateForResource, then routes to ops.update(id) when
?edit=<id> is set or ops.create() when ?new=1. Redirects with
?msg=saved | ?msg=created. On failure, re-renders the panel with
errors + the submitted values.
- delete: calls ops.delete(id), redirects with ?msg=deleted.
- <action.key>: looks up the action in resource.actions and runs its
handler, redirects with ?msg=action_<key>.
404s when the resource key isn't in the registry — most keys won't
resolve until steps 8-10 land. A small .bs-flash banner above the
list surfaces the ?msg= text (or the error message after a failed
save).
Old /admin (?tab=...) continues to work alongside.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Right-slide panel that renders a resource's edit form. Driven by the
URL: ?new=1 opens a fresh form, ?edit=<id> hydrates with the current
item. POSTs back to the same URL with _action (save | delete | <action
key>); the route handler in step 7 dispatches.
- FieldRenderer.astro: dispatches on field.kind, wraps each field with
label + helper text + error state.
- fields/*.astro: one component per kind — Text, Textarea, Markdown
(with Write/Preview toggle), Select, SelectAsync, MultiSelectAsync,
MultiText (with add/remove), Date, Datetime, Number, Readonly.
- ResourceEditPanel.astro: header (title + close X), scrollable body,
sticky footer (save + per-resource secondary actions + destructive
delete when ops.delete is defined and item exists). Scrim closes on
click, Esc, or the close link. Confirm-before-submit honours
action.confirmText. Embedded sub-form sections render a placeholder
until step 8 wires the pulse renderer.
- admin.css: panel chrome + scrim + slide-in keyframes, full field
styling for every kind, mobile full-screen modal collapse.
- preview.astro: exercises every field kind so the panel can be
eyeballed in a logged-in session. Try /admin/preview?new=1 and
/admin/preview?edit=<id>.
Autosave deferred to Phase 2 per the approved deltas.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shared list-rendering component every resource will use. Reads
URL state (?filter, ?q, ?page) and derives:
- active filter (with isDefault fallback)
- active column set (columnsByFilter[filterKey] override → columns)
- filtered + searched + sorted + paginated row set
Rows are full anchor elements pointing at ?edit=<id> so the table
is fully keyboard-navigable and works without JS. The "+ New" button
is suppressed when resource.form is null (activity, join_requests).
- ResourceListView.astro: page header (eyebrow + serif h1 + optional
description + new-item button), toolbar (search form + filter
chips), grid table with --bs-grid-cols set from column widths,
pagination, mobile card collapse.
- ListCell.astro: discriminated render for text / pill / relative-date
/ number / tag-list columns.
- admin.css: list-view styles plus the full pill palette (decision,
update, note, bts, published, draft, archived, open, closed,
pending, accepted, expired, approved, declined, shipping, in-beta,
exploring, considering, active, departed, pilot, cab, fenja).
- preview.astro: inline sample dispatches resource so the list view
renders against real DB rows. Step 8 moves this to its production
config; this inline copy disappears with the preview route in
step 11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-pane Backstage chrome: sticky topbar with the wordmark + " /
Backstage" lockup and a "Back to the portal" link, plus a left
sidebar that walks the resource registry and renders grouped
links with active-state and count badges.
- src/admin/components/AdminLayout.astro — the shell. Pre-resolves
list-counts and notify-counts per resource so the sidebar can
render badges without async work in markup. Renders an empty
state until resources land.
- src/admin/resources/index.ts — empty registry stub. Three groups
declared (publishing, council, system); resources populated in
steps 8–10.
- src/admin/admin.css — Backstage tokens (--admin-sidebar-bg,
--admin-active-accent, etc.) and the shell styles (bs-topbar,
bs-sidebar, bs-resource, bs-count). Mobile collapses the sidebar
above the main pane.
- src/pages/admin/preview.astro — temporary smoke-test route at
/admin/preview. Deleted in step 11 when the new admin replaces
the old one.
Old /admin (?tab=…) is untouched and continues to work.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the ~300-line <style> block from src/pages/admin/index.astro
into a dedicated stylesheet, imported from the page frontmatter.
No rule changes — verbatim extraction so the existing admin UI
continues to render identically.
This is the first commit of the Backstage rebuild: it establishes
the shared admin stylesheet that the resource-pattern components
will consume in subsequent steps.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 0007 (spec said 0006 but 0006 was already roadmap_considering)
adds a single nullable metadata_text column to roadmap_items — a short
admin-set narrative cue shown on hover in the route cards. ~60 chars
suggested in admin helper text. Hidden in the UI when NULL.
db.ts: RoadmapItem type gains the field. createRoadmapItem + updateRoadmapItem
accept an optional metadata_text parameter. moveRoadmapItem passes it through
when swapping display_order between siblings so the helper preserves it.
Admin: /admin?tab=roadmap edit form gets a new 'Hover note' input under
the description, with the helper text and a 120-char hard cap. Empty
string saves as NULL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migration 0006 (the spec said 0005 but that number was already taken by
polls_on_dispatches from the previous session): rebuilds the
roadmap_items CHECK to ('shipping','in_beta','exploring','considering')
and renames any existing 'beta' rows to 'in_beta' in-place. FKs from
roadmap_attributions are preserved across the DROP/RENAME by toggling
PRAGMA foreign_keys off around the rebuild — attribution count unchanged
after migrate (verified 4 rows survive on the demo DB).
Tokens (src/styles/tokens.css): adds --on-ink, --on-ink-body,
--on-ink-muted, --ink-divider. The bleached #fffcf7 cream replaces the
warm #e8e0d0 --ink-text wherever it sits on indigo. Legacy --ink-text /
--ink-muted stay in tokens.css for now — if any later commit references
them they remain defined; the migration of existing call sites is
covered here.
Migrated to the new tokens in this pass:
- src/components/MembershipCard.astro (members/:slug card)
- src/pages/events.astro (hero invitation card)
Both render with cleaner whites on indigo as a side effect.
Code updates for the new status enum:
- db.ts: RoadmapStatus = shipping | in_beta | exploring | considering
- admin/RoadmapTab.astro: Status select gains Considering + In beta;
grouped section iteration covers all four
- admin/index.astro: validation list updated
- scripts/seed-roadmap.js: 'In progress' markdown bucket → 'in_beta'
- pulse.astro: roadmapStatusDot + roadmapStatusBlurb temporarily widened
(full rewrite of that section lands in step 7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Schema (migration 0005): dispatches gains a nullable pulse_id FK to
pulses(id) ON DELETE SET NULL. Partial index on the populated rows.
The pulses + votes tables themselves are unchanged — vote uniqueness,
status derivation, and the existing tests still hold; only the entity
relationship changes.
db.ts:
- Dispatch type gains pulse_id. New DispatchWithPoll = DispatchWithAuthor
+ a hydrated poll (pulse + counts + viewer's vote).
- createDispatch accepts an optional poll input — if provided, creates the
pulse first in the same transaction and stamps dispatches.pulse_id.
- updateDispatch grows two new arguments: poll (input or null) and a
pollExplicit flag. The flag distinguishes "leave the existing poll
alone" (undefined) from "the admin actively chose to detach / replace
it" (true). The detach path nulls pulse_id; the replace path mutates
the existing pulse in place via updatePulse so vote history survives.
- publishDispatch / archiveDispatch are now wrappers that also publishPulse
/ closePulse on the attached pulse. Dispatch state drives poll state.
- getDispatchWithPoll(dispatchId, viewerId) — single call for the page
renderers.
Admin:
- The Pulses tab is removed from the admin tab nav. The route + POST
handlers stay in place so existing draft pulses aren't orphaned, but
the entity is no longer a place admins go to think.
- DispatchesTab form gains a poll fieldset: question + 4 option inputs
(first two required if any are filled) + opens_at + closes_at. A
hidden poll_explicit flag tells the server the form intentionally
asserted the poll state (so leaving the fields blank during edit
detaches rather than no-ops). On edit, fields prefill from the
attached pulse if present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New Dispatches tab — full CRUD matching the existing pattern (?tab=
querystring, plain HTML POST, hidden action field, redirect-with-?msg=).
Author select is restricted to Fenja-role users (defaults to the current
admin). Create form has a status toggle (draft / publish on save).
publish_dispatch stamps published_at via the existing helper; archive
preserves it. Body is a monospace textarea so admins can see markdown
without proportional kerning confusion.
Participants tab gains a per-row Edit link. When ?tab=participants&edit=ID
is set, the table is replaced by <UserEditTab>: title input, comma-
separated focus_tags input (parsed server-side via parseFocusTags), a
pull_quote textarea with a 200-char live counter, and a read-only
member_number display (set on role transition to cab). The inline role
dropdown + deactivate stay on the table.
EventsTab — adds audience, duration_label, action_label, notes_url
inputs. Kind <select> now labels office_hours as 'Studio hours' and
exposes working_session as the new fifth option.
Admin action-link / action-cell styles were missing on admin/index.astro
(they were defined only inside per-tab components); added to the page
stylesheet so the new Participants Edit link inherits the same look.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four new tabs added to /admin, matching the existing pattern exactly: ?tab=
querystring, plain HTML POST with hidden action field, redirect-with-?msg=
success path, existing .tabs / .section / .data-table / .form-grid / .input
/ .select / .btn-primary / .danger-btn classes — no new form library, no JS
beyond `confirm()`.
Pulses tab — create / edit / publish / close / delete + a results view
(?view=ID) with per-option vote counts and bar charts. Publish writes the
'pulse_opened' activity row and calls notifyPulseOpened() so members can
be notified the same way once the integration lands.
Roadmap tab — full CRUD + multi-select attribution (checkbox grid of
council + pilot users) + up/down arrow reorder within each status column
(JS-free, swaps display_order with neighbour). Status transition to
'shipping' stamps shipped_at exactly once and writes 'roadmap_shipped'
activity.
Events tab — full CRUD + an RSVP summary view (?view=ID) showing going /
interested / declined counts. Slug is required on create and readonly on
edit (it's the URL handle).
Activity tab — read-only debug table of the last 200 activity rows. Per
your call: shipping with the rest, not optional. Saves hours of "why
isn't the ticker showing X" later.
Tab views extracted to src/components/admin/{Pulses,Roadmap,Events,Activity}Tab.astro
to keep admin/index.astro navigable; the POST handlers and data loading
stay in index.astro as the single dispatch point.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>