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:
Kwaku Danso
2026-05-20 16:56:37 +01:00
parent dbddf17e3b
commit dc840bfc14
12 changed files with 1859 additions and 7 deletions
+6
View File
@@ -152,6 +152,12 @@ func run() error {
}
defer invitationCC.Stop()
// Block F — scheduled-message worker. Polls scheduled_messages
// every 30s and dispatches due rows through the same email
// pipeline as the NATS-driven flows.
scheduler := newScheduledMessageWorker(logger, db, combinedEmail, cfg.PublicBaseURL)
go scheduler.Start(rootCtx)
logger.Info("notifier started")
<-rootCtx.Done()
logger.Info("notifier shutting down")
+223
View File
@@ -0,0 +1,223 @@
package main
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/notification"
"github.com/alchemistkay/guestguard/internal/storage"
)
// scheduledMessageWorker is the Tier 2 Block F poller. Every `interval`
// it asks the messages repo for the set of scheduled rows whose send_at
// has passed, claims each one (status: scheduled -> sending), fans out
// to its audience, and records per-recipient delivery rows.
//
// Two-replica safety: ClaimForSending uses a `WHERE status='scheduled'`
// guard so a parallel worker racing on the same row will lose; only the
// claim that flips the row wins.
type scheduledMessageWorker struct {
logger *slog.Logger
repo *storage.MessageRepo
events *storage.EventRepo
guests *storage.GuestRepo
sender notification.GuestEmailDispatcher
publicBaseURL string
interval time.Duration
batchSize int
}
func newScheduledMessageWorker(
logger *slog.Logger,
db *storage.DB,
sender notification.GuestEmailDispatcher,
publicBaseURL string,
) *scheduledMessageWorker {
return &scheduledMessageWorker{
logger: logger.With("worker", "scheduled-messages"),
repo: storage.NewMessageRepo(db),
events: storage.NewEventRepo(db),
guests: storage.NewGuestRepo(db),
sender: sender,
publicBaseURL: publicBaseURL,
interval: 30 * time.Second,
batchSize: 50,
}
}
// Start blocks until ctx is cancelled. It polls on `interval` and runs
// a single batch on each tick.
func (w *scheduledMessageWorker) Start(ctx context.Context) {
w.logger.Info("scheduled-message worker started", "interval", w.interval)
// One immediate run on boot, then the periodic tick.
w.runOnce(ctx)
t := time.NewTicker(w.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
w.logger.Info("scheduled-message worker stopping")
return
case <-t.C:
w.runOnce(ctx)
}
}
}
func (w *scheduledMessageWorker) runOnce(ctx context.Context) {
due, err := w.repo.ListDue(ctx, w.batchSize)
if err != nil {
w.logger.Error("list due messages", "err", err)
return
}
for _, m := range due {
w.processOne(ctx, m)
}
}
// processOne claims, dispatches, and finalises one message. Per-recipient
// failures are logged + recorded but don't abort the batch. A whole-
// batch failure (event gone, etc.) marks the message 'failed'.
func (w *scheduledMessageWorker) processOne(ctx context.Context, m domain.ScheduledMessage) {
claimed, err := w.repo.ClaimForSending(ctx, m.ID)
if err != nil {
w.logger.Error("claim message", "err", err, "message_id", m.ID)
return
}
if !claimed {
// Another worker beat us to it, or the row state changed.
return
}
log := w.logger.With("message_id", m.ID, "event_id", m.EventID, "audience", m.Audience)
log.Info("dispatching scheduled message")
event, err := w.events.Get(ctx, m.EventID)
if err != nil {
log.Error("load event for message", "err", err)
_ = w.repo.MarkFailed(ctx, m.ID)
return
}
recipients, err := w.repo.LoadRecipients(ctx, m.EventID, m.Audience)
if err != nil {
log.Error("load recipients", "err", err)
_ = w.repo.MarkFailed(ctx, m.ID)
return
}
subject := ""
if m.Subject != nil {
subject = renderTemplate(*m.Subject, templateData(event, nil, ""))
}
if subject == "" {
// Cheap fallback so providers don't bounce subject-less mail.
subject = "Update for " + event.Name
}
var sent, failed int
for _, rec := range recipients {
guestLog := log.With("guest_id", rec.GuestID)
if rec.Email == "" {
// SMS-only audiences would be addressed via Twilio; for the
// MVP we just record-and-skip when there's no email.
_ = w.repo.RecordDelivery(ctx, domain.MessageDelivery{
MessageID: m.ID,
GuestID: rec.GuestID,
Status: "skipped",
Error: stringPtr("no email on file"),
})
continue
}
// Build the per-recipient RSVP link. We can't decrypt the hash,
// so when the host originally minted the token-issue path put
// the raw value in the invitation email; for follow-up
// reminders we fall back to "go to the host's event" if the
// recipient hash isn't reversible. Practical setup: most
// hosts will use a custom URL via publicBaseURL.
rsvpURL := w.publicBaseURL
if rsvpURL != "" {
rsvpURL = strings.TrimRight(rsvpURL, "/") + "/rsvp"
}
body := renderTemplate(m.Body, templateData(event, &rec, rsvpURL))
_, sendErr := w.sender.SendGuest(ctx, rec.Email, subject, notification.TmplReminder,
map[string]any{
"Subject": subject,
"GuestName": rec.Name,
"EventName": event.Name,
"Venue": event.Venue,
"EventDate": event.EventDate.Format("Mon 2 Jan 2006 · 15:04"),
"Body": body,
"Link": rsvpURL,
})
status := "sent"
var errStr *string
sentAt := time.Now().UTC()
if sendErr != nil {
status = "failed"
s := sendErr.Error()
errStr = &s
failed++
guestLog.Warn("send failed", "err", sendErr)
} else {
sent++
}
_ = w.repo.RecordDelivery(ctx, domain.MessageDelivery{
MessageID: m.ID,
GuestID: rec.GuestID,
Status: status,
SentAt: &sentAt,
Error: errStr,
})
}
if err := w.repo.MarkSent(ctx, m.ID, sent); err != nil {
log.Error("mark sent", "err", err)
}
log.Info("message dispatched", "sent", sent, "failed", failed, "total", len(recipients))
}
// templateData composes the {{var}} substitution map. nil rec gives a
// "generic" set for rendering the subject without a recipient context;
// per-recipient body rendering passes the real recipient.
func templateData(event *domain.Event, rec *storage.MessageRecipient, rsvpURL string) map[string]string {
d := map[string]string{
"event_name": event.Name,
"event_date": event.EventDate.Format("Mon 2 Jan 2006 · 15:04"),
"venue": event.Venue,
"rsvp_link": rsvpURL,
}
if rec != nil {
d["guest_name"] = rec.Name
}
return d
}
// renderTemplate does single-pass {{var}} replacement. Simple and
// dependency-free; we're not running untrusted template strings here.
func renderTemplate(tpl string, data map[string]string) string {
out := tpl
for k, v := range data {
out = strings.ReplaceAll(out, "{{"+k+"}}", v)
}
return out
}
func stringPtr(s string) *string { return &s }
// silence the unused-import warning when this file is the only consumer
// of these packages in some build configurations.
var (
_ = uuid.Nil
_ = fmt.Sprintf
_ = auth.HashToken
)
+415
View File
@@ -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>
+8 -2
View File
@@ -69,8 +69,8 @@ const loading = ref(true)
// (Collaborators + Branding, configured once) → Analytics (results,
// checked periodically). The two action-y tabs anchor the ends; setup
// clusters in the middle.
type EventTab = 'guests' | 'collaborators' | 'branding' | 'analytics' | 'gate'
const validTabs: EventTab[] = ['guests', 'collaborators', 'branding', 'analytics', 'gate']
type EventTab = 'guests' | 'collaborators' | 'communications' | 'branding' | 'analytics' | 'gate'
const validTabs: EventTab[] = ['guests', 'collaborators', 'communications', 'branding', 'analytics', 'gate']
function tabFromHash(): EventTab {
if (import.meta.client) {
const h = window.location.hash.replace('#', '') as EventTab
@@ -785,6 +785,7 @@ function checkLabel(band?: string): string {
v-for="t in [
{ id: 'guests', label: 'Guests' },
{ id: 'collaborators', label: 'Collaborators' },
{ id: 'communications', label: 'Communications' },
{ id: 'branding', label: 'Branding' },
{ id: 'analytics', label: 'Analytics' },
{ id: 'gate', label: 'Gate' },
@@ -1212,6 +1213,11 @@ function checkLabel(band?: string): string {
<TeamCard :event-id="eventId" :your-role="event.your_role" />
</div>
<!-- Communications (Tier 2 Block F). Reminders + custom broadcasts. -->
<div v-if="activeTab === 'communications' && event" class="mt-2">
<CommunicationsCard :event-id="eventId" :your-role="event.your_role" />
</div>
<!-- Gate (Tier 2 Block G). The user-facing rebrand of the fraud
detector: strictness presets + trusted networks + decision
history, with the technical sliders/CIDR jargon tucked behind
+316
View File
@@ -0,0 +1,316 @@
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"time"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
// messageHandler is the host-facing surface for Tier 2 Block F:
// scheduled reminders + custom broadcasts. Editor+ for writes; viewer+
// for reads via the existing requireRole gate.
type messageHandler struct {
logger *slog.Logger
events *storage.EventRepo
collabs *storage.CollaboratorRepo
repo *storage.MessageRepo
}
// --- response shapes ---
// messageView wraps the persisted row with a delivery summary. We don't
// inline the per-recipient rows here — the host expands a single message
// to drill into those — but the "X of Y delivered" rollup is useful in
// the list view.
type messageView struct {
*domain.ScheduledMessage
DeliveryStats storage.DeliveryStats `json:"delivery_stats"`
}
type listMessagesResponse struct {
Messages []messageView `json:"messages"`
}
// GET /events/{id}/messages — viewer+.
func (h *messageHandler) list(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return
}
msgs, err := h.repo.ListByEvent(r.Context(), eventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list messages")
return
}
views := make([]messageView, 0, len(msgs))
for i := range msgs {
stats, _ := h.repo.DeliveryStats(r.Context(), msgs[i].ID)
views = append(views, messageView{
ScheduledMessage: &msgs[i],
DeliveryStats: stats,
})
}
writeJSON(w, http.StatusOK, listMessagesResponse{Messages: views})
}
// composeMessageRequest is what the Communications tab POSTs / PATCHes.
// SendAt is optional on create — omit it together with status="draft" to
// save a draft, or set it for scheduling. Channel defaults to email.
type composeMessageRequest struct {
SendAt *time.Time `json:"send_at"`
Audience domain.MessageAudience `json:"audience"`
Channel domain.MessageChannel `json:"channel"`
Subject string `json:"subject"`
Body string `json:"body"`
Draft bool `json:"draft"`
}
// recipientCountResponse is what the live "X guests will receive this"
// preview chip on the compose form fetches when the audience picker
// changes.
type recipientCountResponse struct {
Count int `json:"count"`
}
// GET /events/{id}/messages/recipient-count?audience=... — viewer+. Lets
// the compose form show a live count without needing to load the full
// guest list client-side.
func (h *messageHandler) recipientCount(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return
}
audience := domain.MessageAudience(r.URL.Query().Get("audience"))
if !audience.Valid() {
writeError(w, http.StatusBadRequest, "audience must be one of: all|attending|pending|declined|maybe")
return
}
n, err := h.repo.CountRecipients(r.Context(), eventID, audience)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to count recipients")
return
}
writeJSON(w, http.StatusOK, recipientCountResponse{Count: n})
}
// POST /events/{id}/messages — editor+.
//
// The host can save a draft (Draft=true, SendAt nil → status=draft) or
// schedule for later (SendAt set → status=scheduled). Send-immediately is
// just "schedule for now"; we expose a separate send-now route for the
// "send this draft right now" affordance below.
func (h *messageHandler) create(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
var req composeMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
if !req.Audience.Valid() {
writeError(w, http.StatusBadRequest, "audience required (all|attending|pending|declined|maybe)")
return
}
if req.Channel == "" {
req.Channel = domain.ChannelEmail
}
if !req.Channel.Valid() {
writeError(w, http.StatusBadRequest, "channel must be one of: email|sms|both")
return
}
if req.Body == "" {
writeError(w, http.StatusBadRequest, "body is required")
return
}
// Pick the status from send_at + draft flag.
status := domain.StatusScheduled
sendAt := time.Now().UTC()
if req.Draft || req.SendAt == nil {
if req.Draft {
status = domain.StatusDraft
} else {
// No send_at given and not flagged as draft: send now.
status = domain.StatusScheduled
}
} else {
sendAt = req.SendAt.UTC()
}
subj := req.Subject
var subjPtr *string
if subj != "" {
subjPtr = &subj
}
m, err := h.repo.Create(r.Context(), storage.CreateMessageParams{
EventID: eventID,
SendAt: sendAt,
Audience: req.Audience,
Channel: req.Channel,
Subject: subjPtr,
Body: req.Body,
Status: status,
CreatedBy: &hostID,
})
if err != nil {
h.logger.Error("create message", "err", err)
writeError(w, http.StatusInternalServerError, "failed to create message")
return
}
writeJSON(w, http.StatusCreated, m)
}
// PATCH /events/{id}/messages/{message_id} — editor+. Only legal while
// the message is still draft or scheduled (the storage layer enforces
// this with a row lock).
func (h *messageHandler) update(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
msgID, ok := parseIDParam(w, r, "message_id")
if !ok {
return
}
var req composeMessageRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
params := storage.UpdateMessageParams{}
if req.SendAt != nil {
t := req.SendAt.UTC()
params.SendAt = &t
}
if req.Audience != "" {
if !req.Audience.Valid() {
writeError(w, http.StatusBadRequest, "invalid audience")
return
}
params.Audience = &req.Audience
}
if req.Channel != "" {
if !req.Channel.Valid() {
writeError(w, http.StatusBadRequest, "invalid channel")
return
}
params.Channel = &req.Channel
}
if req.Subject != "" {
params.Subject = &req.Subject
}
if req.Body != "" {
params.Body = &req.Body
}
m, err := h.repo.Update(r.Context(), eventID, msgID, params)
if err != nil {
switch {
case errors.Is(err, domain.ErrMessageNotFound):
writeError(w, http.StatusNotFound, "message not found")
case errors.Is(err, domain.ErrMessageNotEditable):
writeError(w, http.StatusConflict, "message can only be edited while scheduled or draft")
default:
h.logger.Error("update message", "err", err)
writeError(w, http.StatusInternalServerError, "failed to update message")
}
return
}
writeJSON(w, http.StatusOK, m)
}
// POST /events/{id}/messages/{message_id}/send-now — editor+.
// Promotes a draft or future-scheduled message to send_at=now, so the
// next scheduler poll picks it up.
func (h *messageHandler) sendNow(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
msgID, ok := parseIDParam(w, r, "message_id")
if !ok {
return
}
if err := h.repo.PromoteToScheduled(r.Context(), eventID, msgID); err != nil {
if errors.Is(err, domain.ErrMessageNotEditable) {
writeError(w, http.StatusConflict, "message has already been sent or cancelled")
return
}
writeError(w, http.StatusInternalServerError, "failed to schedule send")
return
}
w.WriteHeader(http.StatusAccepted)
}
// DELETE /events/{id}/messages/{message_id} — editor+. Cancels a
// scheduled / draft message. Sent or sending messages can't be deleted.
func (h *messageHandler) cancel(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok {
return
}
msgID, ok := parseIDParam(w, r, "message_id")
if !ok {
return
}
if err := h.repo.Cancel(r.Context(), eventID, msgID); err != nil {
if errors.Is(err, domain.ErrMessageNotEditable) {
writeError(w, http.StatusConflict, "message has already been sent")
return
}
writeError(w, http.StatusInternalServerError, "failed to cancel message")
return
}
w.WriteHeader(http.StatusNoContent)
}
+23
View File
@@ -42,6 +42,7 @@ type Server struct {
branding *brandingHandler
uploads *uploadHandler
security *securityHandler
messages *messageHandler
}
type ServerDeps struct {
@@ -103,6 +104,7 @@ func NewServer(deps ServerDeps) (*Server, error) {
brandingRepo := storage.NewBrandingRepo(deps.DB)
allowlistRepo := storage.NewAllowlistRepo(deps.DB)
editNonces := newEditNonceStore(deps.Redis)
messageRepo := storage.NewMessageRepo(deps.DB)
feedbackRepo := storage.NewFeedbackRepo(deps.DB)
// Branding image store. Empty UploadsDir leaves it nil and the upload
@@ -275,6 +277,12 @@ func NewServer(deps ServerDeps) (*Server, error) {
feedback: feedbackRepo,
access: accessRepo,
},
messages: &messageHandler{
logger: deps.Logger,
events: eventRepo,
collabs: collabRepo,
repo: messageRepo,
},
collabs: &collaboratorHandler{
logger: deps.Logger,
events: eventRepo,
@@ -391,6 +399,21 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /events/{id}/access-logs/{log_id}/feedback",
authed(http.HandlerFunc(s.security.recordFeedback)))
// Block F — scheduled messages (reminders + broadcasts).
// All editor+ except the recipient-count preview which is viewer+.
mux.Handle("GET /events/{id}/messages",
authed(http.HandlerFunc(s.messages.list)))
mux.Handle("GET /events/{id}/messages/recipient-count",
authed(http.HandlerFunc(s.messages.recipientCount)))
mux.Handle("POST /events/{id}/messages",
authed(rl("messages_create", 100, 24*time.Hour, userIDKey, http.HandlerFunc(s.messages.create))))
mux.Handle("PATCH /events/{id}/messages/{message_id}",
authed(http.HandlerFunc(s.messages.update)))
mux.Handle("POST /events/{id}/messages/{message_id}/send-now",
authed(http.HandlerFunc(s.messages.sendNow)))
mux.Handle("DELETE /events/{id}/messages/{message_id}",
authed(http.HandlerFunc(s.messages.cancel)))
// Block D — event branding. Reads are viewer+; PUT is editor+. The
// upload endpoint is gated by auth only (any signed-in user can mint
// an image URL; the URL is no use without an event they can edit
+182
View File
@@ -0,0 +1,182 @@
package domain
import (
"errors"
"time"
"github.com/google/uuid"
)
// Tier 2 Block F — reminders + broadcasts.
// MessageAudience picks which slice of an event's guests a message
// targets. "All" means every guest; the response-filtered values mirror
// the RSVP states.
type MessageAudience string
const (
AudienceAll MessageAudience = "all"
AudienceAttending MessageAudience = "attending"
AudiencePending MessageAudience = "pending"
AudienceDeclined MessageAudience = "declined"
AudienceMaybe MessageAudience = "maybe"
)
func (a MessageAudience) Valid() bool {
switch a {
case AudienceAll, AudienceAttending, AudiencePending, AudienceDeclined, AudienceMaybe:
return true
}
return false
}
// MessageChannel is the delivery surface. SMS is gated by tier elsewhere
// (Tier 1 Block F); email is universal.
type MessageChannel string
const (
ChannelEmail MessageChannel = "email"
ChannelSMS MessageChannel = "sms"
ChannelBoth MessageChannel = "both"
)
func (c MessageChannel) Valid() bool {
switch c {
case ChannelEmail, ChannelSMS, ChannelBoth:
return true
}
return false
}
// MessageStatus walks the envelope through its life: draft (composed
// but not scheduled), scheduled (queued for the worker), sending (worker
// picked it up), sent (all deliveries attempted), cancelled (host
// pulled it), failed (worker couldn't proceed at all).
type MessageStatus string
const (
StatusDraft MessageStatus = "draft"
StatusScheduled MessageStatus = "scheduled"
StatusSending MessageStatus = "sending"
StatusSent MessageStatus = "sent"
StatusCancelled MessageStatus = "cancelled"
StatusFailed MessageStatus = "failed"
)
// ScheduledMessage is one host-composed (or auto-seeded) communication
// to a slice of an event's guests.
type ScheduledMessage struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
SendAt time.Time `json:"send_at"`
Audience MessageAudience `json:"audience"`
Channel MessageChannel `json:"channel"`
TemplateKey *string `json:"template_key,omitempty"`
Subject *string `json:"subject,omitempty"`
Body string `json:"body"`
Status MessageStatus `json:"status"`
SentAt *time.Time `json:"sent_at,omitempty"`
RecipientCount *int `json:"recipient_count,omitempty"`
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// MessageDelivery is one recipient's outcome within a message batch.
// Status here is a free-form string rather than an enum so the notifier
// can record provider-specific reasons (bounce / suppressed / opted-out
// etc.) without a new migration each time.
type MessageDelivery struct {
MessageID uuid.UUID `json:"message_id"`
GuestID uuid.UUID `json:"guest_id"`
Status string `json:"status"`
SentAt *time.Time `json:"sent_at,omitempty"`
Error *string `json:"error,omitempty"`
}
// Auto-reminder template keys. The scheduler treats these as hints when
// it picks subject lines / formats; the body can be edited by the host
// just like a custom broadcast.
const (
TemplateReminder7d = "reminder_7d"
TemplateReminder1d = "reminder_1d"
TemplateReminderDayOf = "reminder_dayof"
TemplateLastCall = "last_call"
)
// SeedAutoReminders returns the canonical set of auto-reminder rows for
// an event with the given date. Each row is returned without an ID;
// the caller (EventRepo.Create) inserts them. Rows whose send_at is in
// the past are dropped — a host who creates an event 2 days from the
// big day shouldn't get a "7 days to go" reminder scheduled into
// yesterday.
func SeedAutoReminders(eventID uuid.UUID, eventDate time.Time) []ScheduledMessage {
now := time.Now().UTC()
candidates := []struct {
key string
offset time.Duration
audience MessageAudience
subject string
body string
}{
{
key: TemplateReminder7d,
offset: -7 * 24 * time.Hour,
audience: AudiencePending,
subject: "One week to go — please let us know!",
body: "Hi {{guest_name}},\n\nThe big day for {{event_name}} is just a week away. We haven't heard from you yet — could you let us know whether you'll make it? Just tap the link below.\n\n{{rsvp_link}}\n\nThanks!",
},
{
key: TemplateLastCall,
offset: -3 * 24 * time.Hour,
audience: AudiencePending,
subject: "Last call to RSVP for {{event_name}}",
body: "Hi {{guest_name}},\n\n{{event_name}} is just three days away and we're finalising numbers with the venue. Please RSVP today:\n\n{{rsvp_link}}",
},
{
key: TemplateReminder1d,
offset: -1 * 24 * time.Hour,
audience: AudienceAttending,
subject: "Tomorrow! Quick details for {{event_name}}",
body: "Hi {{guest_name}},\n\nLooking forward to seeing you tomorrow at {{event_name}}.\n\nWhere: {{venue}}\nWhen: {{event_date}}\n\nSafe travels!",
},
{
key: TemplateReminderDayOf,
offset: -3 * time.Hour,
audience: AudienceAttending,
subject: "See you in a few hours at {{event_name}}",
body: "Hi {{guest_name}},\n\nWe're getting ready for you. {{event_name}} kicks off shortly at {{venue}}.\n\nSee you soon!",
},
}
out := make([]ScheduledMessage, 0, len(candidates))
for _, c := range candidates {
sendAt := eventDate.Add(c.offset)
// Skip reminders that would fire in the past — typical when an
// event is created close to the date. The host can still
// compose a custom broadcast.
if sendAt.Before(now) {
continue
}
key := c.key
subj := c.subject
out = append(out, ScheduledMessage{
EventID: eventID,
SendAt: sendAt,
Audience: c.audience,
Channel: ChannelEmail,
TemplateKey: &key,
Subject: &subj,
Body: c.body,
Status: StatusScheduled,
})
}
return out
}
var (
ErrMessageNotFound = errors.New("message not found")
ErrMessageNotEditable = errors.New("message can only be edited while scheduled")
ErrInvalidAudience = errors.New("invalid audience")
ErrInvalidChannel = errors.New("invalid channel")
)
+26
View File
@@ -78,12 +78,38 @@ func (r *EventRepo) Create(ctx context.Context, p CreateEventParams) (*domain.Ev
return nil, fmt.Errorf("seed owner collaborator: %w", err)
}
// Block F: auto-seed reminder messages so the host gets the
// "we'll nudge people for you" experience without lifting a finger.
// Rows whose send_at would fall in the past are skipped by
// SeedAutoReminders — typical for events created close to the date.
// Hosts can edit / cancel any of these from the Communications tab.
for _, m := range domain.SeedAutoReminders(ev.ID, ev.EventDate) {
if _, err := tx.Exec(ctx, `
INSERT INTO scheduled_messages
(event_id, send_at, audience, channel, template_key, subject, body, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, m.EventID, m.SendAt, m.Audience, m.Channel,
m.TemplateKey, m.Subject, m.Body, m.Status); err != nil {
return nil, fmt.Errorf("seed auto-reminder %s: %w",
ifNilString(m.TemplateKey), err)
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return ev, nil
}
// ifNilString is a tiny helper so the error message above stays readable
// when an auto-reminder row somehow doesn't carry a template key.
func ifNilString(p *string) string {
if p == nil {
return "<unknown>"
}
return *p
}
func (r *EventRepo) Get(ctx context.Context, id uuid.UUID) (*domain.Event, error) {
const q = `
SELECT id, host_id, name, slug, event_date, venue, max_capacity, settings, status, created_at, updated_at, fraud_medium_threshold, fraud_high_threshold, fraud_block_threshold
+405
View File
@@ -0,0 +1,405 @@
package storage
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/alchemistkay/guestguard/internal/domain"
)
// MessageRepo owns the scheduled_messages + message_deliveries pair.
// Tier 2 Block F.
type MessageRepo struct {
pool *pgxpool.Pool
}
func NewMessageRepo(db *DB) *MessageRepo {
return &MessageRepo{pool: db.Pool}
}
type CreateMessageParams struct {
EventID uuid.UUID
SendAt time.Time
Audience domain.MessageAudience
Channel domain.MessageChannel
TemplateKey *string
Subject *string
Body string
Status domain.MessageStatus
CreatedBy *uuid.UUID
}
// Create inserts a row and returns the persisted message.
func (r *MessageRepo) Create(ctx context.Context, p CreateMessageParams) (*domain.ScheduledMessage, error) {
const q = `
INSERT INTO scheduled_messages
(event_id, send_at, audience, channel, template_key, subject, body, status, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, event_id, send_at, audience, channel, template_key, subject, body,
status, sent_at, recipient_count, created_by, created_at, updated_at
`
row := r.pool.QueryRow(ctx, q,
p.EventID, p.SendAt, p.Audience, p.Channel, p.TemplateKey, p.Subject,
p.Body, p.Status, p.CreatedBy,
)
return scanMessage(row)
}
// CreateBatch inserts many messages in one transaction. Used by the
// auto-reminder seeding path on event creation.
func (r *MessageRepo) CreateBatch(ctx context.Context, msgs []domain.ScheduledMessage) error {
if len(msgs) == 0 {
return nil
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
const q = `
INSERT INTO scheduled_messages
(event_id, send_at, audience, channel, template_key, subject, body, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
for _, m := range msgs {
if _, err := tx.Exec(ctx, q,
m.EventID, m.SendAt, m.Audience, m.Channel,
m.TemplateKey, m.Subject, m.Body, m.Status,
); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// Get returns a single message by id, scoped to the event so a hostile
// editor on event A can't peek at event B.
func (r *MessageRepo) Get(ctx context.Context, eventID, msgID uuid.UUID) (*domain.ScheduledMessage, error) {
const q = `
SELECT id, event_id, send_at, audience, channel, template_key, subject, body,
status, sent_at, recipient_count, created_by, created_at, updated_at
FROM scheduled_messages
WHERE id = $1 AND event_id = $2
`
m, err := scanMessage(r.pool.QueryRow(ctx, q, msgID, eventID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrMessageNotFound
}
return nil, err
}
return m, nil
}
// ListByEvent returns every message for the event, newest first.
func (r *MessageRepo) ListByEvent(ctx context.Context, eventID uuid.UUID) ([]domain.ScheduledMessage, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, event_id, send_at, audience, channel, template_key, subject, body,
status, sent_at, recipient_count, created_by, created_at, updated_at
FROM scheduled_messages
WHERE event_id = $1
ORDER BY created_at DESC
`, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []domain.ScheduledMessage{}
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, err
}
out = append(out, *m)
}
return out, rows.Err()
}
type UpdateMessageParams struct {
SendAt *time.Time
Audience *domain.MessageAudience
Channel *domain.MessageChannel
Subject *string
Body *string
}
// Update patches a scheduled message. Refuses unless status='scheduled'
// or 'draft' — once a message is sending/sent/cancelled, edits are not
// safe. Returns ErrMessageNotEditable when the state forbids the edit.
func (r *MessageRepo) Update(ctx context.Context, eventID, msgID uuid.UUID, p UpdateMessageParams) (*domain.ScheduledMessage, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
var status domain.MessageStatus
if err := tx.QueryRow(ctx, `
SELECT status FROM scheduled_messages
WHERE id = $1 AND event_id = $2
FOR UPDATE
`, msgID, eventID).Scan(&status); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrMessageNotFound
}
return nil, err
}
if status != domain.StatusScheduled && status != domain.StatusDraft {
return nil, domain.ErrMessageNotEditable
}
const upd = `
UPDATE scheduled_messages
SET send_at = COALESCE($3, send_at),
audience = COALESCE($4, audience),
channel = COALESCE($5, channel),
subject = COALESCE($6, subject),
body = COALESCE($7, body),
updated_at = now()
WHERE id = $1 AND event_id = $2
RETURNING id, event_id, send_at, audience, channel, template_key, subject, body,
status, sent_at, recipient_count, created_by, created_at, updated_at
`
m, err := scanMessage(tx.QueryRow(ctx, upd,
msgID, eventID, p.SendAt, p.Audience, p.Channel, p.Subject, p.Body,
))
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return m, nil
}
// Cancel marks a still-scheduled message as cancelled. A sending/sent
// message can't be cancelled.
func (r *MessageRepo) Cancel(ctx context.Context, eventID, msgID uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `
UPDATE scheduled_messages
SET status = 'cancelled', updated_at = now()
WHERE id = $1 AND event_id = $2
AND status IN ('draft', 'scheduled')
`, msgID, eventID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrMessageNotEditable
}
return nil
}
// PromoteToScheduled flips a draft / future-scheduled row to "send right
// now" by setting send_at and (if needed) status. Powers the
// /messages/{id}/send-now endpoint.
func (r *MessageRepo) PromoteToScheduled(ctx context.Context, eventID, msgID uuid.UUID) error {
tag, err := r.pool.Exec(ctx, `
UPDATE scheduled_messages
SET send_at = now(),
status = 'scheduled',
updated_at = now()
WHERE id = $1 AND event_id = $2
AND status IN ('draft', 'scheduled')
`, msgID, eventID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrMessageNotEditable
}
return nil
}
// ListDue returns up to `limit` messages whose send_at has passed. The
// scheduler worker calls this every poll interval.
func (r *MessageRepo) ListDue(ctx context.Context, limit int) ([]domain.ScheduledMessage, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := r.pool.Query(ctx, `
SELECT id, event_id, send_at, audience, channel, template_key, subject, body,
status, sent_at, recipient_count, created_by, created_at, updated_at
FROM scheduled_messages
WHERE status = 'scheduled' AND send_at <= now()
ORDER BY send_at ASC
LIMIT $1
`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []domain.ScheduledMessage{}
for rows.Next() {
m, err := scanMessage(rows)
if err != nil {
return nil, err
}
out = append(out, *m)
}
return out, rows.Err()
}
// ClaimForSending atomically transitions a single message from
// 'scheduled' to 'sending', returning whether the claim succeeded. Used
// by the worker to avoid two replicas double-sending the same message.
func (r *MessageRepo) ClaimForSending(ctx context.Context, msgID uuid.UUID) (bool, error) {
tag, err := r.pool.Exec(ctx, `
UPDATE scheduled_messages
SET status = 'sending', updated_at = now()
WHERE id = $1 AND status = 'scheduled'
`, msgID)
if err != nil {
return false, err
}
return tag.RowsAffected() == 1, nil
}
// MarkSent flips a 'sending' message to 'sent', recording how many
// recipients the worker actually addressed.
func (r *MessageRepo) MarkSent(ctx context.Context, msgID uuid.UUID, recipientCount int) error {
_, err := r.pool.Exec(ctx, `
UPDATE scheduled_messages
SET status = 'sent',
sent_at = now(),
recipient_count = $2,
updated_at = now()
WHERE id = $1
`, msgID, recipientCount)
return err
}
// MarkFailed parks a message in 'failed' state with no recipients
// dispatched. Used when the worker can't even compose the message
// (event went away, etc.).
func (r *MessageRepo) MarkFailed(ctx context.Context, msgID uuid.UUID) error {
_, err := r.pool.Exec(ctx, `
UPDATE scheduled_messages
SET status = 'failed', updated_at = now()
WHERE id = $1
`, msgID)
return err
}
// RecordDelivery upserts one per-recipient outcome.
func (r *MessageRepo) RecordDelivery(ctx context.Context, d domain.MessageDelivery) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO message_deliveries (message_id, guest_id, status, sent_at, error)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (message_id, guest_id) DO UPDATE SET
status = EXCLUDED.status,
sent_at = EXCLUDED.sent_at,
error = EXCLUDED.error
`, d.MessageID, d.GuestID, d.Status, d.SentAt, d.Error)
return err
}
// DeliveryCounts returns "X of Y delivered" for the per-message line in
// the UI without pulling every delivery row.
type DeliveryStats struct {
Sent int `json:"sent"`
Failed int `json:"failed"`
Total int `json:"total"`
}
func (r *MessageRepo) DeliveryStats(ctx context.Context, msgID uuid.UUID) (DeliveryStats, error) {
var s DeliveryStats
err := r.pool.QueryRow(ctx, `
SELECT
count(*) FILTER (WHERE status = 'sent') AS sent,
count(*) FILTER (WHERE status IN ('failed','bounced','skipped')) AS failed,
count(*) AS total
FROM message_deliveries
WHERE message_id = $1
`, msgID).Scan(&s.Sent, &s.Failed, &s.Total)
return s, err
}
// MessageRecipient is one guest the audience filter resolved to for a
// given message. The notifier renders the body per recipient using
// these fields so {{guest_name}}, {{rsvp_link}}, etc. land correctly.
type MessageRecipient struct {
GuestID uuid.UUID
Name string
Email string
Phone string
TokenHash string // for link reconstruction (host pairs this with the raw token)
}
// LoadRecipients returns the audience-filtered slice of guests for a
// message. Only guests with an email (when channel includes email) or
// phone (when channel includes sms) are returned — others go on the
// skipped pile at delivery time.
func (r *MessageRepo) LoadRecipients(ctx context.Context, eventID uuid.UUID, audience domain.MessageAudience) ([]MessageRecipient, error) {
// Build the WHERE clause based on audience. We always include
// guests on the event; the audience condition adds an inner join /
// filter on rsvps.
var where string
switch audience {
case domain.AudienceAll:
where = ""
case domain.AudiencePending:
where = "AND NOT EXISTS (SELECT 1 FROM rsvps r WHERE r.guest_id = g.id)"
case domain.AudienceAttending:
where = "AND EXISTS (SELECT 1 FROM rsvps r WHERE r.guest_id = g.id AND r.response = 'attending')"
case domain.AudienceDeclined:
where = "AND EXISTS (SELECT 1 FROM rsvps r WHERE r.guest_id = g.id AND r.response = 'declined')"
case domain.AudienceMaybe:
where = "AND EXISTS (SELECT 1 FROM rsvps r WHERE r.guest_id = g.id AND r.response = 'maybe')"
default:
return nil, domain.ErrInvalidAudience
}
q := `
SELECT g.id, g.name, COALESCE(g.email,''), COALESCE(g.phone,''),
COALESCE(t.token_hash, '')
FROM guests g
LEFT JOIN tokens t ON t.guest_id = g.id
WHERE g.event_id = $1
` + where + `
ORDER BY g.created_at ASC
`
rows, err := r.pool.Query(ctx, q, eventID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []MessageRecipient{}
for rows.Next() {
var rec MessageRecipient
if err := rows.Scan(&rec.GuestID, &rec.Name, &rec.Email, &rec.Phone, &rec.TokenHash); err != nil {
return nil, err
}
out = append(out, rec)
}
return out, rows.Err()
}
// CountRecipients is the fast-path of LoadRecipients used by the UI's
// "X guests will receive this" preview before send.
func (r *MessageRepo) CountRecipients(ctx context.Context, eventID uuid.UUID, audience domain.MessageAudience) (int, error) {
recs, err := r.LoadRecipients(ctx, eventID, audience)
if err != nil {
return 0, err
}
return len(recs), nil
}
func scanMessage(s rowScanner) (*domain.ScheduledMessage, error) {
var m domain.ScheduledMessage
err := s.Scan(
&m.ID, &m.EventID, &m.SendAt, &m.Audience, &m.Channel,
&m.TemplateKey, &m.Subject, &m.Body,
&m.Status, &m.SentAt, &m.RecipientCount, &m.CreatedBy,
&m.CreatedAt, &m.UpdatedAt,
)
if err != nil {
return nil, err
}
return &m, nil
}
@@ -0,0 +1,10 @@
DROP INDEX IF EXISTS idx_deliveries_message;
DROP TABLE IF EXISTS message_deliveries;
DROP INDEX IF EXISTS idx_messages_event;
DROP INDEX IF EXISTS idx_messages_due;
DROP TABLE IF EXISTS scheduled_messages;
DROP TYPE IF EXISTS message_status;
DROP TYPE IF EXISTS message_channel;
DROP TYPE IF EXISTS message_audience;
@@ -0,0 +1,72 @@
-- Tier 2 Block F — reminders + broadcasts.
--
-- The Tier 2 plan called this the messages pipeline. Two tables:
--
-- scheduled_messages — one row per message envelope. Status moves
-- scheduled -> sending -> sent (or cancelled / failed).
-- message_deliveries — one row per recipient. Lets a partial-failure
-- batch keep the rows that did succeed and surface
-- the rest in the UI.
--
-- The scheduler worker (cmd/notifier) polls scheduled_messages by
-- send_at; the index supports that without a sequential scan even on
-- thousands of pending rows.
DO $$ BEGIN
CREATE TYPE message_audience AS ENUM ('all', 'attending', 'pending', 'declined', 'maybe');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE message_channel AS ENUM ('email', 'sms', 'both');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE message_status AS ENUM ('draft', 'scheduled', 'sending', 'sent', 'cancelled', 'failed');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
CREATE TABLE IF NOT EXISTS scheduled_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
send_at TIMESTAMPTZ NOT NULL,
audience message_audience NOT NULL,
channel message_channel NOT NULL,
-- template_key tags the auto-seeded reminders ('reminder_7d',
-- 'reminder_1d', 'reminder_dayof', 'last_call'). NULL for hand-
-- composed broadcasts.
template_key TEXT,
subject TEXT,
body TEXT NOT NULL,
status message_status NOT NULL DEFAULT 'draft',
sent_at TIMESTAMPTZ,
recipient_count INTEGER,
-- created_by is the user who scheduled or composed the message.
-- NULL for system-seeded auto-reminders.
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The scheduler's hot query: "what's due right now?" Partial index keeps
-- it small even on events with hundreds of historical sent messages.
CREATE INDEX IF NOT EXISTS idx_messages_due
ON scheduled_messages (send_at)
WHERE status = 'scheduled';
-- "Show me this event's communications history" — used by the
-- Communications tab. Sorted newest-first so the index covers ORDER BY.
CREATE INDEX IF NOT EXISTS idx_messages_event
ON scheduled_messages (event_id, created_at DESC);
CREATE TABLE IF NOT EXISTS message_deliveries (
message_id UUID NOT NULL REFERENCES scheduled_messages(id) ON DELETE CASCADE,
guest_id UUID NOT NULL REFERENCES guests(id) ON DELETE CASCADE,
status TEXT NOT NULL, -- 'pending' | 'sent' | 'bounced' | 'skipped' | 'failed'
sent_at TIMESTAMPTZ,
error TEXT,
PRIMARY KEY (message_id, guest_id)
);
-- "Which delivers succeeded for this message?" — used by the per-message
-- drill-down in the UI.
CREATE INDEX IF NOT EXISTS idx_deliveries_message
ON message_deliveries (message_id, status);
+168
View File
@@ -0,0 +1,168 @@
//go:build integration
package integration_test
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/google/uuid"
)
// Tier 2 Block F — scheduled messages (reminders + broadcasts).
type messagePersisted struct {
ID uuid.UUID `json:"id"`
EventID uuid.UUID `json:"event_id"`
Audience string `json:"audience"`
Channel string `json:"channel"`
Status string `json:"status"`
TemplateKey *string `json:"template_key"`
Subject *string `json:"subject"`
Body string `json:"body"`
}
type listMessagesBody struct {
Messages []struct {
ID uuid.UUID `json:"id"`
Status string `json:"status"`
TemplateKey *string `json:"template_key"`
Body string `json:"body"`
} `json:"messages"`
}
// TestAutoReminderSeeding confirms that creating an event in the future
// also seeds the reminder schedule. The seed is conservative: only
// reminders whose send_at would fall after "now" land in the DB.
func TestAutoReminderSeeding(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Far Future", "far-future")
// createEvent defaults event_date to +30d, so all four templates
// (7d / last_call / 1d / dayof) should seed.
var listed listMessagesBody
getJSONAuthed(t, fmt.Sprintf("%s/events/%s/messages", srv.URL, eventID),
token, http.StatusOK, &listed)
if len(listed.Messages) < 3 {
t.Fatalf("expected at least 3 auto-reminders, got %d", len(listed.Messages))
}
seen := map[string]bool{}
for _, m := range listed.Messages {
if m.TemplateKey != nil {
seen[*m.TemplateKey] = true
}
if m.Status != "scheduled" {
t.Errorf("auto-reminder status: got %q want scheduled", m.Status)
}
}
for _, want := range []string{"reminder_7d", "last_call", "reminder_1d", "reminder_dayof"} {
if !seen[want] {
t.Errorf("missing auto-reminder template %q", want)
}
}
}
// TestComposeAndEditMessage walks the create -> list -> patch -> cancel
// path. Confirms the status machine: edits OK while scheduled, send-now
// flips to scheduled with send_at=now, cancel turns it to cancelled and
// blocks further edits.
func TestComposeAndEditMessage(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, _, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Compose", "compose-test")
// Compose a draft.
var created messagePersisted
postJSONAuthed(t, fmt.Sprintf("%s/events/%s/messages", srv.URL, eventID),
token,
map[string]any{
"audience": "pending",
"channel": "email",
"subject": "Pls reply",
"body": "Hi {{guest_name}}, we'd love to know if you can make it!",
"draft": true,
},
http.StatusCreated, &created)
if created.Status != "draft" {
t.Fatalf("expected draft status, got %q", created.Status)
}
// Patch the body. Status: still draft.
patchJSON(t, fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
token,
map[string]any{
"body": "Updated copy",
},
http.StatusOK, nil)
// Send-now: promotes the draft to scheduled with send_at=now.
assertStatus(t, http.MethodPost,
fmt.Sprintf("%s/events/%s/messages/%s/send-now", srv.URL, eventID, created.ID),
token, nil, http.StatusAccepted)
// Cancel: works while scheduled.
assertStatus(t, http.MethodDelete,
fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
token, nil, http.StatusNoContent)
// PATCH on a cancelled row is refused.
assertStatus(t, http.MethodPatch,
fmt.Sprintf("%s/events/%s/messages/%s", srv.URL, eventID, created.ID),
token,
map[string]any{"body": "too late"},
http.StatusConflict)
}
// TestRecipientCountAudienceFilter confirms the live preview returns
// counts matching the audience filter.
func TestRecipientCountAudienceFilter(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in -short mode")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
srv, db, _, token := setupAuthedAPI(t, ctx)
eventID := createEvent(t, srv.URL, token, "Audience", "audience-test")
// Seed five guests: 2 attending, 1 declined, 1 maybe, 1 pending.
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "A1", 2, "attending", 0, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "A2", 0, "attending", 1, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "D1", 0, "declined", 0, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "M1", 0, "maybe", 0, true)
seedAnalyticsGuest(t, ctx, db.Pool, eventID, "P1", 0, "", 0, false)
cases := map[string]int{
"all": 5,
"attending": 2,
"declined": 1,
"maybe": 1,
"pending": 1,
}
for audience, want := range cases {
var got struct {
Count int `json:"count"`
}
getJSONAuthed(t,
fmt.Sprintf("%s/events/%s/messages/recipient-count?audience=%s",
srv.URL, eventID, audience),
token, http.StatusOK, &got)
if got.Count != want {
t.Errorf("audience=%s: got %d want %d", audience, got.Count, want)
}
}
}