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>
This commit is contained in:
Jonathan Hvid 2026-05-12 10:14:50 +02:00
parent cafbcf8b74
commit 867661ee3d
4 changed files with 237 additions and 28 deletions

View file

@ -0,0 +1,10 @@
-- Polls are no longer a standalone entity in the UX: every poll is attached
-- to a dispatch. We keep the pulses + votes tables (vote uniqueness, status
-- derivation, admin history) and add a nullable FK from dispatches.
--
-- ON DELETE SET NULL — if an attached pulse is hard-deleted, the dispatch
-- survives without a poll rather than vanishing with it.
ALTER TABLE dispatches ADD COLUMN pulse_id INTEGER REFERENCES pulses(id) ON DELETE SET NULL;
CREATE INDEX idx_dispatches_pulse ON dispatches(pulse_id) WHERE pulse_id IS NOT NULL;

View file

@ -1,16 +1,25 @@
---
import type { DispatchWithAuthor, UserPublic } from '../../lib/db';
import type { DispatchWithAuthor, UserPublic, PulseRow } from '../../lib/db';
import { fmtDateTime } from '../../lib/markdown';
import { dispatchKindLabel } from '../../lib/format';
interface Props {
dispatches: DispatchWithAuthor[];
editing: DispatchWithAuthor | null;
editingPoll: PulseRow | null;
fenjaUsers: UserPublic[];
currentUserId: number;
}
const { dispatches, editing, fenjaUsers, currentUserId } = Astro.props;
const { dispatches, editing, editingPoll, fenjaUsers, currentUserId } = Astro.props;
function toInputValue(sql: string | null | undefined): string {
if (!sql) return '';
return sql.replace(' ', 'T').slice(0, 16);
}
const pollOptionsForForm: string[] = editingPoll ? [...editingPoll.options] : [];
while (pollOptionsForForm.length < 4) pollOptionsForForm.push('');
const STATUS_LABEL: Record<string, string> = {
draft: 'Draft',
@ -78,6 +87,65 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
</div>
)}
<!-- ── Attached poll (optional) ────────────────────────────── -->
<fieldset class="poll-fieldset">
<legend class="label-sm field-label">Attach a poll (optional)</legend>
<input type="hidden" name="poll_explicit" value="1" />
<p class="body-sm muted poll-help">
Fill in a question and at least two options to attach a poll. Leave them all blank
to {editingPoll ? 'detach the existing poll' : 'skip'}.
{editingPoll && <span class="poll-existing-flag"> · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.</span>}
</p>
<div class="field">
<label for="d-poll-question" class="label-sm field-label">Poll question</label>
<input
type="text"
id="d-poll-question"
name="poll_question"
class="input body-md"
value={editingPoll?.question ?? ''}
placeholder={editing ? editing.title : 'A question for the council'}
/>
</div>
<div class="poll-options-grid">
{pollOptionsForForm.map((val, i) => (
<input
type="text"
name={`poll_option_${i}`}
placeholder={`Option ${String.fromCharCode(65 + i)}${i < 2 ? '' : ' (optional)'}`}
class="input body-md"
value={val}
/>
))}
</div>
<div class="form-grid">
<div class="field">
<label for="d-poll-opens" class="label-sm field-label">Poll opens at (UTC)</label>
<input
type="datetime-local"
id="d-poll-opens"
name="poll_opens_at"
class="input body-md"
value={toInputValue(editingPoll?.opens_at)}
/>
</div>
<div class="field">
<label for="d-poll-closes" class="label-sm field-label">Poll closes at (UTC)</label>
<input
type="datetime-local"
id="d-poll-closes"
name="poll_closes_at"
class="input body-md"
value={toInputValue(editingPoll?.closes_at)}
/>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn-primary label-sm">{editing ? 'Save changes' : 'Save dispatch'}</button>
{editing && <a href="/admin?tab=dispatches" class="action-link label-sm">Cancel</a>}
@ -144,6 +212,30 @@ const defaultAuthorId = editing?.author_id ?? currentUserId;
.mono { font-family: var(--font-mono); font-size: var(--text-body-sm); }
.form-actions { display: flex; gap: var(--space-3); align-items: center; }
.poll-fieldset {
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-md);
padding: var(--space-5);
display: flex;
flex-direction: column;
gap: var(--space-4);
margin: 0;
}
.poll-fieldset legend {
padding: 0 var(--space-2);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.poll-help { color: var(--on-surface-muted); margin: 0; }
.poll-existing-flag { color: var(--pigment-terracotta); }
.poll-options-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.muted { color: var(--on-surface-muted); }
.status-pill {
display: inline-block;
padding: 0.15em var(--space-3);

View file

@ -1004,6 +1004,7 @@ export interface Dispatch {
published_at: string | null;
created_at: string;
updated_at: string;
pulse_id: number | null; // attached poll, if any
}
export interface DispatchWithAuthor extends Dispatch {
@ -1012,6 +1013,18 @@ export interface DispatchWithAuthor extends Dispatch {
author_role: Role;
}
export interface DispatchWithPoll extends DispatchWithAuthor {
poll: PulseWithCounts | null;
}
/** Optional poll attachment used when creating/updating a dispatch. */
export interface DispatchPollInput {
question: string;
options: string[];
opens_at: string;
closes_at: string;
}
export function createDispatch(data: {
title: string;
body: string;
@ -1019,34 +1032,99 @@ export function createDispatch(data: {
kind: DispatchKind;
author_id: number;
status: DispatchStatus;
poll?: DispatchPollInput | null;
}): number {
const published_at = data.status === 'published'
? new Date().toISOString().slice(0, 19).replace('T', ' ')
: null;
return db.transaction(() => {
let pulseId: number | null = null;
if (data.poll && data.poll.options.length >= 2) {
pulseId = createPulse({
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
status: data.status === 'published' ? 'open' : 'draft',
created_by: data.author_id,
});
}
const r = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at)
VALUES (?,?,?,?,?,?,?)
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at);
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, pulse_id)
VALUES (?,?,?,?,?,?,?,?)
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, data.status, published_at, pulseId);
return Number(r.lastInsertRowid);
})();
}
/** Update a dispatch and, optionally, manage its attached poll. */
export function updateDispatch(id: number, data: {
title: string;
body: string;
excerpt: string | null;
kind: DispatchKind;
author_id: number;
poll?: DispatchPollInput | null; // present + has options ⇒ attach/update; explicit null ⇒ detach
pollExplicit?: boolean; // distinguishes "leave poll alone" (undefined) from "detach" (null + flag)
}): void {
db.transaction(() => {
const cur = db.prepare('SELECT pulse_id, status FROM dispatches WHERE id = ?')
.get(id) as { pulse_id: number | null; status: DispatchStatus } | undefined;
if (!cur) return;
let pulseId: number | null = cur.pulse_id;
if (data.pollExplicit) {
if (data.poll && data.poll.options.length >= 2) {
if (cur.pulse_id) {
// update the existing pulse in place
updatePulse(cur.pulse_id, {
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
});
} else {
pulseId = createPulse({
question: data.poll.question,
context: null,
options: data.poll.options,
opens_at: data.poll.opens_at,
closes_at: data.poll.closes_at,
status: cur.status === 'published' ? 'open' : 'draft',
created_by: data.author_id,
});
}
} else {
// explicit detach
pulseId = null;
}
}
db.prepare(`
UPDATE dispatches
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now')
SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?,
pulse_id = ?, updated_at = datetime('now')
WHERE id = ?
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id);
`).run(data.title, data.body, data.excerpt, data.kind, data.author_id, pulseId, id);
})();
}
/** Dispatch + its attached poll (with counts + this viewer's vote). */
export function getDispatchWithPoll(dispatchId: number, viewerId: number): DispatchWithPoll | null {
const d = getDispatchById(dispatchId);
if (!d) return null;
const poll = d.pulse_id ? getPulseWithCounts(d.pulse_id, viewerId) : null;
return { ...d, poll };
}
/** Promote draft published, stamping published_at = now() on first publish.
* Idempotent: if already published, published_at is preserved. */
* Idempotent: if already published, published_at is preserved. Also opens
* any attached draft poll so members can start voting. */
export function publishDispatch(id: number): void {
db.transaction(() => {
db.prepare(`
UPDATE dispatches
SET status = 'published',
@ -1054,11 +1132,19 @@ export function publishDispatch(id: number): void {
updated_at = datetime('now')
WHERE id = ?
`).run(id);
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
if (row?.pulse_id) publishPulse(row.pulse_id);
})();
}
/** Archive a dispatch. Leaves published_at intact for history. */
/** Archive a dispatch. Leaves published_at intact for history. Closes any
* attached open poll so the bar charts read final. */
export function archiveDispatch(id: number): void {
db.transaction(() => {
db.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id);
const row = db.prepare('SELECT pulse_id FROM dispatches WHERE id = ?').get(id) as { pulse_id: number | null } | undefined;
if (row?.pulse_id) closePulse(row.pulse_id);
})();
}
export function deleteDispatch(id: number): void {

View file

@ -101,14 +101,35 @@ if (Astro.request.method === 'POST') {
const authorId = Number(data.get('author_id'));
const status = String(data.get('status') ?? 'draft') as DispatchStatus;
// Parse optional poll attachment fields.
const pollExplicit = String(data.get('poll_explicit') ?? '') === '1';
const pollQuestion = String(data.get('poll_question') ?? '').trim();
const pollOpts = [0, 1, 2, 3]
.map(i => String(data.get(`poll_option_${i}`) ?? '').trim())
.filter(s => s.length > 0);
const pollOpens = String(data.get('poll_opens_at') ?? '');
const pollCloses = String(data.get('poll_closes_at') ?? '');
let pollInput: { question: string; options: string[]; opens_at: string; closes_at: string } | null = null;
if (pollQuestion && pollOpts.length >= 2 && pollOpens && pollCloses) {
pollInput = {
question: pollQuestion,
options: pollOpts,
opens_at: toSqlDate(pollOpens),
closes_at: toSqlDate(pollCloses),
};
}
if (!title || !body || !['decision','update','behind_the_scenes','note'].includes(kind)) {
formError = 'Title, body, and a valid kind are required.';
} else if (action === 'create_dispatch') {
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status });
createDispatch({ title, body, excerpt, kind, author_id: authorId || user.id, status, poll: pollInput });
return Astro.redirect('/admin?tab=dispatches&msg=dispatch_created');
} else {
const id = Number(data.get('dispatch_id'));
if (id) updateDispatch(id, { title, body, excerpt, kind, author_id: authorId || user.id });
if (id) updateDispatch(id, {
title, body, excerpt, kind, author_id: authorId || user.id,
poll: pollInput, pollExplicit,
});
return Astro.redirect(`/admin?tab=dispatches&edit=${id}&msg=dispatch_updated`);
}
} else if (action === 'publish_dispatch') {
@ -283,6 +304,7 @@ const editingUser = tab === 'participants' && editId ? getUserPublicById(editId)
const dispatches = tab === 'dispatches' ? getAllDispatchesForAdmin() : [];
const dispatchEditing = tab === 'dispatches' && editId ? getDispatchById(editId) : null;
const dispatchEditingPoll = dispatchEditing?.pulse_id ? getPulseById(dispatchEditing.pulse_id) : null;
// Per-tab data
const pulses = tab === 'pulses' ? getAllPulses() : [];
@ -335,12 +357,11 @@ actionMsg = Astro.url.searchParams.get('msg');
<h1 class="display-md page-title">Control panel.</h1>
</header>
<!-- Tabs -->
<!-- Tabs (Pulses entity merged into Dispatches — polls now attach to articles) -->
<div class="tabs">
<a href="/admin?tab=pulses" class:list={['tab label-sm', { active: tab === 'pulses' }]}>Pulses</a>
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
<a href="/admin?tab=roadmap" class:list={['tab label-sm', { active: tab === 'roadmap' }]}>Roadmap</a>
<a href="/admin?tab=events" class:list={['tab label-sm', { active: tab === 'events' }]}>Events</a>
<a href="/admin?tab=dispatches" class:list={['tab label-sm', { active: tab === 'dispatches' }]}>Dispatches</a>
<a href="/admin?tab=invitations" class:list={['tab label-sm', { active: tab === 'invitations' }]}>Invitations</a>
<a href="/admin?tab=participants" class:list={['tab label-sm', { active: tab === 'participants' }]}>Participants</a>
<a href="/admin?tab=join" class:list={['tab label-sm', { active: tab === 'join' }]}>
@ -577,7 +598,7 @@ actionMsg = Astro.url.searchParams.get('msg');
)}
{tab === 'dispatches' && (
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} fenjaUsers={fenjaUsers} currentUserId={user.id} />
<DispatchesTab dispatches={dispatches} editing={dispatchEditing} editingPoll={dispatchEditingPoll} fenjaUsers={fenjaUsers} currentUserId={user.id} />
)}
</div>