diff --git a/migrations/0005_polls_on_dispatches.sql b/migrations/0005_polls_on_dispatches.sql new file mode 100644 index 0000000..323e1ae --- /dev/null +++ b/migrations/0005_polls_on_dispatches.sql @@ -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; diff --git a/src/components/admin/DispatchesTab.astro b/src/components/admin/DispatchesTab.astro index c66fcab..a0628bd 100644 --- a/src/components/admin/DispatchesTab.astro +++ b/src/components/admin/DispatchesTab.astro @@ -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 = { draft: 'Draft', @@ -78,6 +87,65 @@ const defaultAuthorId = editing?.author_id ?? currentUserId; )} + +
+ Attach a poll (optional) + + +

+ 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 && · Currently attached: pulse #{editingPoll.id}, status {editingPoll.status}.} +

+ +
+ + +
+ +
+ {pollOptionsForForm.map((val, i) => ( + + ))} +
+ +
+
+ + +
+
+ + +
+
+
+
{editing && Cancel} @@ -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); diff --git a/src/lib/db.ts b/src/lib/db.ts index c8da261..a54d95b 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -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,46 +1032,119 @@ 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; - 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); - return Number(r.lastInsertRowid); + 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, 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.prepare(` - UPDATE dispatches - SET title = ?, body = ?, excerpt = ?, kind = ?, author_id = ?, updated_at = datetime('now') - WHERE id = ? - `).run(data.title, data.body, data.excerpt, data.kind, data.author_id, id); + 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 = ?, + pulse_id = ?, updated_at = datetime('now') + WHERE 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.prepare(` - UPDATE dispatches - SET status = 'published', - published_at = COALESCE(published_at, datetime('now')), - updated_at = datetime('now') - WHERE id = ? - `).run(id); + db.transaction(() => { + db.prepare(` + UPDATE dispatches + SET status = 'published', + published_at = COALESCE(published_at, datetime('now')), + 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.prepare("UPDATE dispatches SET status = 'archived', updated_at = datetime('now') WHERE id = ?").run(id); + 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 { diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro index dd8c35e..be1bc3b 100644 --- a/src/pages/admin/index.astro +++ b/src/pages/admin/index.astro @@ -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');

Control panel.

- +