/* --------------------------------------------------------------------------- * 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 = { 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); }, }, ], };