Commit graph

16 commits

Author SHA1 Message Date
a520e8534e fix(admin): invite magic link is absolute, not relative
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>
2026-05-13 10:29:58 +02:00
8bbf8568f4 feat(admin): retire old admin, add resource verifier, redirect /admin
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>
2026-05-12 16:52:48 +02:00
e9a986d484 feat(admin): council-group resources (users, invitations, join requests)
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>
2026-05-12 16:32:26 +02:00
dd9ea68fab feat(admin): publishing-group resources (dispatches, roadmap, events)
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>
2026-05-12 16:24:13 +02:00
3aaa21e6af feat(admin): /admin/[resource] dynamic route + POST dispatch
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>
2026-05-12 16:15:17 +02:00
09a10061b2 feat(admin): ResourceEditPanel + field renderers (no autosave)
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>
2026-05-12 16:10:39 +02:00
cc9332e6e2 feat(admin): ResourceListView + ListCell with filter-conditional columns
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>
2026-05-12 16:05:15 +02:00
dd7215d828 feat(admin): AdminLayout shell + empty resource registry
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>
2026-05-12 16:00:57 +02:00
103bfa2f0c refactor(admin): extract inline styles to src/admin/admin.css
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>
2026-05-12 15:53:10 +02:00
d17d9b93a7 feat(db): roadmap_items.metadata_text + admin field
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>
2026-05-12 11:39:18 +02:00
9ae8422527 feat(db): roadmap_items gains 'considering' + 'in_beta' rename, --on-ink tokens
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>
2026-05-12 10:46:39 +02:00
867661ee3d feat(polls): polls attach to dispatches — standalone Pulses entity retired
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>
2026-05-12 10:14:50 +02:00
fd3f433933 feat(admin): Dispatches tab + user-edit form + extended event form
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>
2026-05-11 16:10:20 +02:00
f6e7337c5e feat(admin): pulses, roadmap, events, activity tabs
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>
2026-05-11 14:57:44 +02:00
Jonathan
4bed3a5fe0 feat: join_requests table and join CTA flow 2026-04-19 20:29:09 +02:00
Jonathan
7f02600c05 feat: admin panel 2026-04-18 22:52:29 +02:00