feat(tier2): calendar integration — Block B
After confirming attendance (or revisiting an already-attending RSVP),
guests can add the event to Google, Outlook, Apple, or any iCalendar
client with one click.
- internal/calendar package builds an RFC 5545 VCALENDAR plus the three
provider deep-links from a narrow Event projection. Times are emitted
as Z-suffixed UTC; the UID is deterministic (event-uuid@guestguard)
so re-downloads update the same calendar entry instead of duplicating
- GET /access/{token}/calendar.ics returns the .ics with a slugified
filename in Content-Disposition; same rate-limit class as /access
- GET /access/{token} response now embeds google/outlook/yahoo/ics URLs
so the frontend doesn't re-encode per provider
- AddToCalendar.vue renders the four buttons; shown only when the
guest is attending (declined/maybe don't need a calendar entry)
- Unit tests cover ics field presence, RFC 5545 escaping (comma,
semicolon), CRLF line endings, explicit-end handling, omitted
optionals, provider URL shape, filename slugification
- Integration: ics download returns valid VCALENDAR with the event
UID + name; unknown token returns 404; /access embeds the links
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,12 +8,20 @@ interface ExistingRSVP {
|
||||
edit_count: number
|
||||
}
|
||||
|
||||
interface CalendarLinks {
|
||||
google: string
|
||||
outlook: string
|
||||
yahoo: string
|
||||
ics: string
|
||||
}
|
||||
|
||||
interface AccessResponse {
|
||||
guest: { id: string; name: string; email?: string | null; plus_ones: number }
|
||||
event: { id: string; name: string; venue: string; event_date: string }
|
||||
token: { id: string; status: string; expires_at: string }
|
||||
access_log_id: string
|
||||
rsvp?: ExistingRSVP | null
|
||||
calendar?: CalendarLinks
|
||||
}
|
||||
|
||||
interface RSVPSubmitResponse {
|
||||
@@ -130,6 +138,11 @@ function fmtDate(iso?: string) {
|
||||
try { return new Date(iso).toLocaleString() } catch { return iso }
|
||||
}
|
||||
|
||||
// Calendar links come from the backend so we don't reimplement Google /
|
||||
// Outlook / Yahoo encoding rules per provider. They're available as soon
|
||||
// as /access loads — the guest can grab them before submitting.
|
||||
const calendar = computed<CalendarLinks | null>(() => access.value?.calendar ?? null)
|
||||
|
||||
// showForm = no prior submission, or the guest has clicked "Change my response"
|
||||
const showForm = computed(() => editing.value || !existing.value)
|
||||
const submitLabel = computed(() => {
|
||||
@@ -169,6 +182,13 @@ const submitLabel = computed(() => {
|
||||
Risk score {{ result.fraud.score }} · {{ result.fraud.risk }}
|
||||
<span v-if="!result.fraud.used"> · fallback</span>
|
||||
</p>
|
||||
|
||||
<AddToCalendar
|
||||
v-if="calendar && result.rsvp.response === 'attending'"
|
||||
:links="calendar"
|
||||
class="mt-5 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
<div v-if="!editLimitReached" class="mt-5 flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
Need to change something? You have {{ editsRemaining }}
|
||||
@@ -193,6 +213,13 @@ const submitLabel = computed(() => {
|
||||
<span v-if="existing.plus_ones > 0"> with +{{ existing.plus_ones }} plus-ones</span>
|
||||
on {{ fmtDate(existing.submitted_at) }}.
|
||||
</p>
|
||||
|
||||
<AddToCalendar
|
||||
v-if="calendar && existing.response === 'attending'"
|
||||
:links="calendar"
|
||||
class="mb-4 border-t border-zinc-800 pt-4"
|
||||
/>
|
||||
|
||||
<div v-if="!editLimitReached" class="flex items-center justify-between gap-3 border-t border-zinc-800 pt-4">
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ editsRemaining }} {{ editsRemaining === 1 ? 'edit' : 'edits' }} remaining.
|
||||
|
||||
Reference in New Issue
Block a user