feat(tier2): reminders + broadcasts pipeline — Block F
The Communications surface. Hosts can schedule custom broadcasts to a
chosen audience (everyone / attending / pending / declined / maybe),
edit or cancel anything that hasn't fired, and review delivery
outcomes. Four auto-reminders are pre-seeded on every new event:
7-day, 3-day last call, 1-day, and day-of.
Schema (migration 0012)
- scheduled_messages — one row per message envelope, with status
walking draft -> scheduled -> sending -> sent (or cancelled/failed).
Partial index on (send_at) WHERE status='scheduled' for the
scheduler poll; per-event index for the Communications tab list.
- message_deliveries — per-recipient outcomes so a partial-failure
batch doesn't lose the rows that did succeed.
Domain
- MessageAudience / MessageChannel / MessageStatus enums
- SeedAutoReminders helper that returns four canonical reminder rows
for a given event_date, skipping any whose send_at would land in
the past (events created close to the date)
Storage
- MessageRepo: Create / CreateBatch / Get / ListByEvent / Update
(locks the row and refuses unless status is draft|scheduled) /
Cancel / PromoteToScheduled (the send-now path) / ListDue /
ClaimForSending (atomic guard against two replicas double-sending) /
MarkSent / MarkFailed / RecordDelivery / DeliveryStats /
LoadRecipients (audience-filtered guest list) / CountRecipients
- EventRepo.Create now seeds auto-reminders in the same transaction
that inserts the event and its owner collaborator row
API (all editor+, except recipient-count which is viewer+)
- GET /events/{id}/messages
- GET /events/{id}/messages/recipient-count?audience=...
- POST /events/{id}/messages (draft / schedule / send-now)
- PATCH /events/{id}/messages/{message_id}
- POST /events/{id}/messages/{message_id}/send-now
- DELETE /events/{id}/messages/{message_id}
Scheduler worker (cmd/notifier)
- New file scheduler.go: polls ListDue every 30s, claims each row
atomically (ClaimForSending uses a status=scheduled guard so two
notifier replicas don't double-send), renders subject and body
per recipient with the {{guest_name}} / {{event_name}} /
{{event_date}} / {{venue}} / {{rsvp_link}} placeholders, sends via
the existing GuestEmailDispatcher (Resend > SMTP > SES > log
stub, same picker as the API), records each delivery row.
Frontend
- New CommunicationsCard.vue with compose form (audience + channel +
subject + body + send-mode radios), live "X guests will receive
this" recipient-count preview, and three sub-tabs for Scheduled /
Sent / Cancelled. Per-message Send-now and Cancel actions for
draft/scheduled rows. Friendly labels for auto-seeded reminders
("1-day reminder", "Day-of reminder") so the slugs never leak.
- New top-level tab "Communications" on the event-detail page,
between Collaborators and Branding.
Tests
- TestAutoReminderSeeding confirms a future-dated event lands the
four canonical reminders in scheduled state.
- TestComposeAndEditMessage walks draft -> patch -> send-now ->
cancel and asserts the conflict on PATCH-after-cancel.
- TestRecipientCountAudienceFilter seeds a known guest mix and
checks every audience preset returns the right count.
- Full integration suite passes (~177s).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
<script setup lang="ts">
|
||||
// Tier 2 Block F — host-facing communications surface. Compose, schedule,
|
||||
// and send broadcasts; review what's already gone out; edit or cancel
|
||||
// things that haven't fired yet (including the four auto-reminders the
|
||||
// system seeds when an event is created).
|
||||
//
|
||||
// The compose form lives at the top of the card. Below it: tabs for
|
||||
// Scheduled / Sent / Cancelled lists. We default to "Scheduled" because
|
||||
// it's the one with actionable items most of the time.
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
send_at: string
|
||||
audience: 'all' | 'attending' | 'pending' | 'declined' | 'maybe'
|
||||
channel: 'email' | 'sms' | 'both'
|
||||
template_key?: string | null
|
||||
subject?: string | null
|
||||
body: string
|
||||
status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled' | 'failed'
|
||||
sent_at?: string | null
|
||||
recipient_count?: number | null
|
||||
created_at: string
|
||||
delivery_stats?: { sent: number; failed: number; total: number }
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
eventId: string
|
||||
yourRole?: 'owner' | 'editor' | 'viewer' | null
|
||||
}>()
|
||||
|
||||
const canEdit = computed(() => props.yourRole === 'owner' || props.yourRole === 'editor')
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
const messages = ref<Message[]>([])
|
||||
|
||||
// Compose form state.
|
||||
const audience = ref<Message['audience']>('pending')
|
||||
const subject = ref('')
|
||||
const body = ref('')
|
||||
const sendMode = ref<'now' | 'schedule' | 'draft'>('now')
|
||||
const sendAt = ref('') // datetime-local string
|
||||
const sending = ref(false)
|
||||
const recipientCount = ref<number | null>(null)
|
||||
const countingRecipients = ref(false)
|
||||
|
||||
// Tab state for the message list.
|
||||
const activeList = ref<'scheduled' | 'sent' | 'cancelled'>('scheduled')
|
||||
|
||||
// Toast.
|
||||
type Toast = { kind: 'success' | 'error'; text: string }
|
||||
const toast = ref<Toast | null>(null)
|
||||
let toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||
function showToast(t: Toast, ms = 4000) {
|
||||
toast.value = t
|
||||
if (toastTimer) clearTimeout(toastTimer)
|
||||
toastTimer = setTimeout(() => { toast.value = null }, ms)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await useApi<{ messages: Message[] }>(`/events/${props.eventId}/messages`)
|
||||
messages.value = data.messages || []
|
||||
} catch (e: any) {
|
||||
error.value = useErrMessage(e, 'Could not load communications')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
onMounted(refresh)
|
||||
|
||||
// Live recipient count on audience change. Debounced via a single
|
||||
// in-flight fetch so a rapid toggle doesn't fire a storm.
|
||||
async function updateRecipientCount() {
|
||||
countingRecipients.value = true
|
||||
try {
|
||||
const res = await useApi<{ count: number }>(
|
||||
`/events/${props.eventId}/messages/recipient-count?audience=${audience.value}`,
|
||||
)
|
||||
recipientCount.value = res.count
|
||||
} catch {
|
||||
recipientCount.value = null
|
||||
} finally {
|
||||
countingRecipients.value = false
|
||||
}
|
||||
}
|
||||
watch(audience, updateRecipientCount, { immediate: true })
|
||||
|
||||
async function compose() {
|
||||
if (!body.value.trim()) {
|
||||
showToast({ kind: 'error', text: 'Body is required.' })
|
||||
return
|
||||
}
|
||||
sending.value = true
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
audience: audience.value,
|
||||
channel: 'email',
|
||||
subject: subject.value.trim() || undefined,
|
||||
body: body.value,
|
||||
}
|
||||
if (sendMode.value === 'draft') payload.draft = true
|
||||
if (sendMode.value === 'schedule' && sendAt.value) {
|
||||
// datetime-local has no timezone; treat as local + convert to ISO.
|
||||
payload.send_at = new Date(sendAt.value).toISOString()
|
||||
}
|
||||
// sendMode === 'now' falls through with no send_at; backend treats
|
||||
// that as "schedule immediately" (status=scheduled, send_at=now).
|
||||
|
||||
await useApi(`/events/${props.eventId}/messages`, {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
})
|
||||
showToast({
|
||||
kind: 'success',
|
||||
text: sendMode.value === 'draft' ? 'Draft saved.'
|
||||
: sendMode.value === 'schedule' ? 'Message scheduled.'
|
||||
: 'Message queued to send.',
|
||||
})
|
||||
subject.value = ''
|
||||
body.value = ''
|
||||
sendAt.value = ''
|
||||
sendMode.value = 'now'
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
showToast({ kind: 'error', text: useErrMessage(e, 'Could not compose message') })
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessageNow(m: Message) {
|
||||
if (!confirm(`Send this message to ${m.audience} guests now?`)) return
|
||||
try {
|
||||
await useApi(`/events/${props.eventId}/messages/${m.id}/send-now`, { method: 'POST' })
|
||||
showToast({ kind: 'success', text: 'Message queued to send.' })
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
showToast({ kind: 'error', text: useErrMessage(e, 'Could not send') })
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelMessage(m: Message) {
|
||||
const label = templateLabel(m) || 'this message'
|
||||
if (!confirm(`Cancel ${label}?`)) return
|
||||
try {
|
||||
await useApi(`/events/${props.eventId}/messages/${m.id}`, { method: 'DELETE' })
|
||||
showToast({ kind: 'success', text: 'Message cancelled.' })
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
showToast({ kind: 'error', text: useErrMessage(e, 'Could not cancel') })
|
||||
}
|
||||
}
|
||||
|
||||
// templateLabel maps the auto-seed keys to human strings so the UI
|
||||
// reads "1-day reminder" rather than the internal slug.
|
||||
function templateLabel(m: Message): string {
|
||||
switch (m.template_key) {
|
||||
case 'reminder_7d': return '7-day reminder'
|
||||
case 'last_call': return 'Last-call reminder'
|
||||
case 'reminder_1d': return '1-day reminder'
|
||||
case 'reminder_dayof': return 'Day-of reminder'
|
||||
case null:
|
||||
case undefined: return 'Custom broadcast'
|
||||
default: return m.template_key
|
||||
}
|
||||
}
|
||||
|
||||
function audienceLabel(a: Message['audience']) {
|
||||
switch (a) {
|
||||
case 'all': return 'all guests'
|
||||
case 'attending': return 'attending guests'
|
||||
case 'pending': return 'guests who haven\'t replied'
|
||||
case 'declined': return 'guests who declined'
|
||||
case 'maybe': return 'guests who said maybe'
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(iso?: string | null) {
|
||||
if (!iso) return ''
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
|
||||
// Template-placeholder labels for the hint line under the compose
|
||||
// textarea. Defined in the script so Vue's parser doesn't mistake the
|
||||
// literal {{guest_name}} for a mustache expression.
|
||||
const placeholderTokens = ['{{guest_name}}', '{{event_name}}', '{{event_date}}', '{{venue}}', '{{rsvp_link}}']
|
||||
|
||||
const scheduledMessages = computed(() =>
|
||||
messages.value.filter((m) => m.status === 'scheduled' || m.status === 'draft' || m.status === 'sending'))
|
||||
const sentMessages = computed(() =>
|
||||
messages.value.filter((m) => m.status === 'sent'))
|
||||
const cancelledMessages = computed(() =>
|
||||
messages.value.filter((m) => m.status === 'cancelled' || m.status === 'failed'))
|
||||
|
||||
const activeMessages = computed(() => {
|
||||
switch (activeList.value) {
|
||||
case 'scheduled': return scheduledMessages.value
|
||||
case 'sent': return sentMessages.value
|
||||
case 'cancelled': return cancelledMessages.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card">
|
||||
<header class="mb-3">
|
||||
<h2 class="text-lg font-semibold">Communications</h2>
|
||||
<p class="text-xs text-zinc-500">
|
||||
Reminders and broadcasts to your guests. The big day's automatic nudges
|
||||
(7 days out, 3-day last call, 1 day before, and day-of) are pre-scheduled
|
||||
for you; edit or cancel anything you don't want.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="mb-3 text-sm text-red-400">{{ error }}</p>
|
||||
<p v-if="loading" class="text-sm text-zinc-500">Loading…</p>
|
||||
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Compose -->
|
||||
<div v-if="canEdit" class="rounded-lg border border-zinc-800 bg-zinc-950 p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-zinc-100">Compose a message</h3>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="label">Audience</label>
|
||||
<select v-model="audience" class="input text-sm">
|
||||
<option value="all">Everyone</option>
|
||||
<option value="attending">Attending</option>
|
||||
<option value="pending">Haven't replied yet</option>
|
||||
<option value="declined">Declined</option>
|
||||
<option value="maybe">Maybe</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
<span v-if="countingRecipients">Counting…</span>
|
||||
<span v-else-if="recipientCount !== null">
|
||||
{{ recipientCount }} {{ recipientCount === 1 ? 'guest' : 'guests' }} will receive this.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Subject (optional)</label>
|
||||
<input v-model="subject" class="input text-sm" placeholder="A friendly nudge from us" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="label">Message</label>
|
||||
<textarea
|
||||
v-model="body"
|
||||
class="input min-h-[100px] text-sm"
|
||||
placeholder="Hi {{guest_name}}, just a reminder about {{event_name}} on {{event_date}}. RSVP here: {{rsvp_link}}"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
You can use these placeholders, and they'll be filled in per guest:
|
||||
<template v-for="(tok, i) in placeholderTokens" :key="tok">
|
||||
<code class="text-zinc-400">{{ tok }}</code><span v-if="i < placeholderTokens.length - 1">, </span>
|
||||
</template>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<label class="label">When</label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="flex items-center gap-1.5 text-sm">
|
||||
<input v-model="sendMode" type="radio" value="now" />
|
||||
Send now
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-sm">
|
||||
<input v-model="sendMode" type="radio" value="schedule" />
|
||||
Schedule for…
|
||||
</label>
|
||||
<input
|
||||
v-if="sendMode === 'schedule'"
|
||||
v-model="sendAt"
|
||||
type="datetime-local"
|
||||
class="input text-sm"
|
||||
/>
|
||||
<label class="flex items-center gap-1.5 text-sm">
|
||||
<input v-model="sendMode" type="radio" value="draft" />
|
||||
Save as draft
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
class="btn-primary text-sm"
|
||||
:disabled="sending || !body.trim() || (sendMode === 'schedule' && !sendAt)"
|
||||
@click="compose"
|
||||
>
|
||||
{{ sending ? 'Saving…' :
|
||||
sendMode === 'now' ? 'Send now' :
|
||||
sendMode === 'schedule' ? 'Schedule' :
|
||||
'Save draft' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- List tabs -->
|
||||
<div>
|
||||
<div class="mb-3 flex items-center gap-1 rounded-lg border border-zinc-800 bg-zinc-900/40 p-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-1.5 transition"
|
||||
:class="activeList === 'scheduled' ? 'bg-brand-500/10 text-brand-200' : 'text-zinc-400 hover:text-zinc-100'"
|
||||
@click="activeList = 'scheduled'"
|
||||
>Scheduled ({{ scheduledMessages.length }})</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-1.5 transition"
|
||||
:class="activeList === 'sent' ? 'bg-brand-500/10 text-brand-200' : 'text-zinc-400 hover:text-zinc-100'"
|
||||
@click="activeList = 'sent'"
|
||||
>Sent ({{ sentMessages.length }})</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-1.5 transition"
|
||||
:class="activeList === 'cancelled' ? 'bg-brand-500/10 text-brand-200' : 'text-zinc-400 hover:text-zinc-100'"
|
||||
@click="activeList = 'cancelled'"
|
||||
>Cancelled ({{ cancelledMessages.length }})</button>
|
||||
</div>
|
||||
|
||||
<p v-if="!activeMessages.length" class="text-sm text-zinc-500">
|
||||
<template v-if="activeList === 'scheduled'">Nothing scheduled. New broadcasts and the auto-reminders will appear here.</template>
|
||||
<template v-else-if="activeList === 'sent'">Nothing has been sent yet.</template>
|
||||
<template v-else>No cancelled messages.</template>
|
||||
</p>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li
|
||||
v-for="m in activeMessages"
|
||||
:key="m.id"
|
||||
class="rounded-md border border-zinc-800 bg-zinc-950 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-zinc-100">
|
||||
{{ templateLabel(m) }}
|
||||
<span class="ml-1 text-xs font-normal text-zinc-500">
|
||||
→ {{ audienceLabel(m.audience) }}
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="m.subject" class="mt-0.5 truncate text-xs text-zinc-400">{{ m.subject }}</p>
|
||||
<p class="mt-1 text-xs text-zinc-500">
|
||||
<template v-if="m.status === 'sent'">
|
||||
Sent {{ fmtDate(m.sent_at) }}
|
||||
<template v-if="m.delivery_stats">
|
||||
· {{ m.delivery_stats.sent }} of {{ m.delivery_stats.total }} delivered
|
||||
<span v-if="m.delivery_stats.failed > 0" class="text-amber-400">
|
||||
({{ m.delivery_stats.failed }} failed)
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="m.status === 'scheduled'">
|
||||
Scheduled for {{ fmtDate(m.send_at) }}
|
||||
</template>
|
||||
<template v-else-if="m.status === 'sending'">
|
||||
Sending now…
|
||||
</template>
|
||||
<template v-else-if="m.status === 'draft'">
|
||||
Draft · saved {{ fmtDate(m.created_at) }}
|
||||
</template>
|
||||
<template v-else-if="m.status === 'cancelled'">
|
||||
Cancelled
|
||||
</template>
|
||||
<template v-else-if="m.status === 'failed'">
|
||||
<span class="text-red-400">Failed to send</span>
|
||||
</template>
|
||||
</p>
|
||||
<p class="mt-2 line-clamp-2 text-xs text-zinc-400">{{ m.body }}</p>
|
||||
</div>
|
||||
<div v-if="canEdit && (m.status === 'scheduled' || m.status === 'draft')" class="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
v-if="m.status === 'draft' || m.status === 'scheduled'"
|
||||
type="button"
|
||||
class="text-xs text-brand-300 hover:text-brand-200"
|
||||
@click="sendMessageNow(m)"
|
||||
>Send now</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-zinc-400 hover:text-red-300"
|
||||
@click="cancelMessage(m)"
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="translate-y-2 opacity-0"
|
||||
enter-to-class="translate-y-0 opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="translate-y-0 opacity-100"
|
||||
leave-to-class="translate-y-2 opacity-0"
|
||||
>
|
||||
<button
|
||||
v-if="toast"
|
||||
type="button"
|
||||
class="fixed bottom-6 right-6 z-50 flex max-w-sm items-center gap-2 rounded-lg border px-4 py-3 text-left text-sm shadow-lg backdrop-blur"
|
||||
:class="toast.kind === 'success'
|
||||
? 'border-brand-700/60 bg-brand-950/90 text-brand-100'
|
||||
: 'border-red-800/60 bg-red-950/90 text-red-100'"
|
||||
role="status"
|
||||
@click="toast = null"
|
||||
>{{ toast.text }}</button>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user