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:
Kwaku Danso
2026-05-17 19:36:06 +01:00
parent 39533162bb
commit 6803d700b4
7 changed files with 603 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
// Renders the four add-to-calendar buttons (Google, Outlook, Apple/iCalendar,
// Other). Tier 2 Block B.
//
// Apple Calendar has no web-deeplink — it imports .ics files directly from the
// device. So Apple and "Other" both point at the .ics download URL; the only
// difference is the label so a desktop guest knows which to click.
defineProps<{
links: {
google: string
outlook: string
yahoo: string
ics: string
}
}>()
</script>
<template>
<section aria-label="Add this event to your calendar">
<p class="mb-3 text-xs font-medium uppercase tracking-widest text-brand-500">
Add to calendar
</p>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<!-- Google: opens the prefilled event in a new tab. -->
<a
:href="links.google"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs font-medium text-zinc-200 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M5 4a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H8V3a1 1 0 00-2 0v1H5zm0 5a1 1 0 011-1h8a1 1 0 011 1v6a1 1 0 01-1 1H6a1 1 0 01-1-1V9z" />
</svg>
Google
</a>
<!-- Outlook web: same idea, different domain. -->
<a
:href="links.outlook"
target="_blank"
rel="noopener noreferrer"
class="flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs font-medium text-zinc-200 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M3 4a2 2 0 012-2h10a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V4zm3 5a1 1 0 011-1h6a1 1 0 010 2H7a1 1 0 01-1-1zm0 4a1 1 0 011-1h4a1 1 0 010 2H7a1 1 0 01-1-1z" clip-rule="evenodd" />
</svg>
Outlook
</a>
<!-- Apple Calendar imports .ics from the file system; pointing at the
download triggers the OS handler on macOS/iOS. -->
<a
:href="links.ics"
class="flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs font-medium text-zinc-200 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path d="M10 2a4 4 0 00-3.464 6.001A6 6 0 002 14a2 2 0 002 2h12a2 2 0 002-2 6 6 0 00-4.536-5.999A4 4 0 0010 2z" />
</svg>
Apple
</a>
<!-- Catch-all .ics for everything that isn't a hosted web calendar. -->
<a
:href="links.ics"
class="flex items-center justify-center gap-1.5 rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-xs font-medium text-zinc-200 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM6.293 6.707a1 1 0 010-1.414l3-3a1 1 0 011.414 0l3 3a1 1 0 01-1.414 1.414L11 5.414V13a1 1 0 11-2 0V5.414L7.707 6.707a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
Other (.ics)
</a>
</div>
</section>
</template>
+27
View File
@@ -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.