Files
guestguard/frontend/components/CommunicationsCard.vue
T
Kwaku Danso dc840bfc14 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>
2026-05-20 16:56:37 +01:00

416 lines
16 KiB
Vue

<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>