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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user