project-bifrost-platform/src/admin/resources/dispatches.ts
Jonathan Hvid 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

260 lines
8.7 KiB
TypeScript

/* ---------------------------------------------------------------------------
* Dispatches resource — the canonical example of the resource pattern.
*
* The optional pulse sub-form is read out of ctx.formData by the create/update
* handlers (pulse_question / pulse_options[] / pulse_opens_at / pulse_closes_at).
* Status changes ride on the same Save submit: when the form's chosen status
* differs from the current one, publishDispatch / archiveDispatch fire after
* the content save.
* ------------------------------------------------------------------------- */
import {
createDispatch,
updateDispatch,
publishDispatch,
archiveDispatch,
deleteDispatch,
getDispatchById,
getAllDispatchesForAdmin,
getAllUsersPublic,
type DispatchKind,
type DispatchPollInput,
type DispatchStatus,
type DispatchWithAuthor,
} from '../../lib/db';
import { dispatchSlug } from '../../lib/format';
import type { FieldContext, Resource } from '../resource-types';
// ── Helpers ─────────────────────────────────────────────────────────────────
/** "YYYY-MM-DDTHH:mm" (datetime-local) → "YYYY-MM-DD HH:mm:ss" (SQLite). */
function toSqliteDatetime(s: string): string {
if (!s) return '';
return s.replace('T', ' ') + (s.length === 16 ? ':00' : '');
}
function nowSqlite(): string {
return new Date().toISOString().slice(0, 19).replace('T', ' ');
}
function plusDaysSqlite(days: number): string {
return new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
}
/** Read pulse_* fields out of FormData. Returns null when no question was provided. */
function extractPulseFromFormData(formData: FormData): DispatchPollInput | null {
const question = String(formData.get('pulse_question') ?? '').trim();
if (!question) return null;
const options = formData
.getAll('pulse_options')
.map((v) => String(v).trim())
.filter(Boolean)
.slice(0, 4);
if (options.length < 2) return null;
const opens_at =
toSqliteDatetime(String(formData.get('pulse_opens_at') ?? '').trim()) || nowSqlite();
const closes_at =
toSqliteDatetime(String(formData.get('pulse_closes_at') ?? '').trim()) || plusDaysSqlite(14);
return { question, options, opens_at, closes_at };
}
// ── Resource ────────────────────────────────────────────────────────────────
export const dispatchesResource: Resource<DispatchWithAuthor> = {
key: 'dispatches',
label: 'Dispatches',
pluralLabel: 'Dispatches',
singularLabel: 'Dispatch',
groupKey: 'publishing',
description: 'Updates, decisions, notes — the public record of pilot progress.',
publicRoutePattern: (item) => `/dispatches/${dispatchSlug(item)}`,
list: {
queryFn: () => getAllDispatchesForAdmin(),
columns: [
{
key: 'title',
label: 'Title',
primary: true,
width: '2fr',
render: (item) => ({
title: item.title,
subtitle: item.author_name,
}),
},
{
key: 'kind',
label: 'Kind',
kind: 'pill',
width: '140px',
pillVariants: {
decision: { label: 'Decision', class: 'pill-decision' },
update: { label: 'Update', class: 'pill-update' },
note: { label: 'Note', class: 'pill-note' },
behind_the_scenes: { label: 'Behind the scenes', class: 'pill-bts' },
},
},
{
key: 'status',
label: 'Status',
kind: 'pill',
width: '110px',
pillVariants: {
draft: { label: 'Draft', class: 'pill-draft' },
published: { label: 'Published', class: 'pill-published' },
archived: { label: 'Archived', class: 'pill-archived' },
},
},
{
key: 'updated_at',
label: 'Updated',
kind: 'relative-date',
width: '110px',
emptyFallback: '—',
},
],
filters: [
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'published', label: 'Published', predicate: (i) => i.status === 'published' },
{ key: 'drafts', label: 'Drafts', predicate: (i) => i.status === 'draft' },
{ key: 'archived', label: 'Archived', predicate: (i) => i.status === 'archived' },
],
search: {
placeholder: 'Search by title or body…',
fields: ['title', 'body'],
},
defaultSort: { key: 'updated_at', direction: 'desc' },
pageSize: 25,
},
// Drafts in the sidebar light up terracotta until they're published.
notifyCount: {
count: (items) => items.filter((i) => i.status === 'draft').length,
},
form: {
fields: [
{ key: 'title', label: 'Title', kind: 'text', required: true, maxLength: 200 },
{
key: 'kind',
label: 'Kind',
kind: 'select',
options: [
{ value: 'decision', label: 'Decision' },
{ value: 'update', label: 'Update' },
{ value: 'note', label: 'Note' },
{ value: 'behind_the_scenes', label: 'Behind the scenes' },
],
defaultValue: 'note',
},
{
key: 'author_id',
label: 'Author',
kind: 'select-async',
required: true,
loadOptions: () =>
getAllUsersPublic()
.filter((u) => u.role === 'fenja')
.map((u) => ({ value: u.id, label: u.name })),
defaultValue: (ctx: FieldContext) => ctx.actingUserId,
},
{
key: 'excerpt',
label: 'Excerpt',
kind: 'textarea',
rows: 4,
helperText:
'Two to four sentences. The first sentence becomes the lead paragraph on the dispatch banner. Leave blank to fall back to the first ~200 chars of body.',
},
{
key: 'body',
label: 'Body (markdown)',
kind: 'markdown',
rows: 14,
required: true,
},
{
key: 'status',
label: 'Status on save',
kind: 'select',
options: [
{ value: 'draft', label: 'Draft (hidden from members)' },
{ value: 'published', label: 'Published (visible immediately)' },
{ value: 'archived', label: 'Archived (hidden from members, kept here)' },
],
defaultValue: 'draft',
helperText:
'Switching from draft to published is the same as clicking the Publish action — the dispatch becomes visible to all members.',
},
],
embeds: [{ key: 'pulse', title: 'Pulse', component: 'pulse-sub-form' }],
},
ops: {
getById: (id) => getDispatchById(id),
create: (data, ctx) => {
const poll = ctx.formData ? extractPulseFromFormData(ctx.formData) : null;
const status = (data.status as DispatchStatus) ?? 'draft';
return createDispatch({
title: String(data.title),
body: String(data.body),
excerpt: ((data.excerpt as string) ?? '').trim() || null,
kind: data.kind as DispatchKind,
author_id: Number(data.author_id),
status,
poll,
});
},
update: (id, data, ctx) => {
const poll = ctx.formData ? extractPulseFromFormData(ctx.formData) : null;
const current = getDispatchById(id);
if (!current) throw new Error(`Dispatch ${id} not found`);
updateDispatch(id, {
title: String(data.title),
body: String(data.body),
excerpt: ((data.excerpt as string) ?? '').trim() || null,
kind: data.kind as DispatchKind,
author_id: Number(data.author_id),
poll,
// Only flag pollExplicit when a question was actually submitted.
// Empty pulse fields leave the existing pulse alone.
pollExplicit: poll !== null,
});
// Status transitions ride on the save submit.
const desiredStatus = (data.status as DispatchStatus) ?? current.status;
if (desiredStatus !== current.status) {
if (desiredStatus === 'published') publishDispatch(id);
else if (desiredStatus === 'archived') archiveDispatch(id);
// 'draft' from another state is a no-op — there's no "unpublish".
}
},
delete: (id) => deleteDispatch(id),
},
actions: [
{
key: 'publish',
label: 'Publish now',
visibleWhen: (item) => item.status === 'draft',
confirmText: 'Publish this dispatch to all members?',
handler: (id) => {
publishDispatch(id);
},
},
{
key: 'archive',
label: 'Archive',
visibleWhen: (item) => item.status === 'published',
destructive: true,
confirmText: 'Archive this dispatch? It will be hidden from members.',
handler: (id) => {
archiveDispatch(id);
},
},
],
};