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
+5
View File
@@ -305,6 +305,11 @@ func (s *Server) Handler() http.Handler {
// of their source IP.
mux.Handle("GET /access/{token}",
rl("access", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.access)))
// Block B: .ics download. Same rate-limit class as /access since the
// payload is similarly cheap and the abuse profile is identical (one
// token per attacker).
mux.Handle("GET /access/{token}/calendar.ics",
rl("calendar_ics", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.calendar)))
mux.Handle("POST /rsvp/{token}",
rl("rsvp", 10, time.Hour, pathKey("token"), http.HandlerFunc(s.rsvps.submit)))
// Block A: edits are bounded by MaxRSVPEdits server-side. The redis
+75
View File
@@ -12,6 +12,7 @@ import (
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/calendar"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
@@ -410,6 +411,10 @@ type accessResponse struct {
// page can show an edit form instead of a fresh submit form when the
// guest revisits their invitation link (Tier 2 Block A).
RSVP *domain.RSVP `json:"rsvp,omitempty"`
// Calendar holds the add-to-calendar deep-links and the .ics download
// path so the frontend renders four ready-to-click buttons after a
// successful RSVP. (Tier 2 Block B.)
Calendar calendar.ProviderLinks `json:"calendar"`
}
// GET /access/{token} — validate token, log the access attempt, publish to NATS.
@@ -498,9 +503,79 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) {
Token: tk,
AccessLog: accessLogID,
RSVP: existingRSVP,
Calendar: h.calendarLinks(event, raw),
})
}
// calendarLinks renders the three provider URLs + .ics path for the event.
// The raw access token is embedded in the .ics path so the download endpoint
// stays public (no auth) while still scoped to a single invitation.
func (h *tokenHandler) calendarLinks(event *domain.Event, rawToken string) calendar.ProviderLinks {
return calendar.BuildLinks(eventForCalendar(event), h.calendarICSURL(rawToken))
}
func (h *tokenHandler) calendarICSURL(rawToken string) string {
base := h.publicBaseURL
if base == "" {
base = "http://localhost:8080"
}
return base + "/access/" + rawToken + "/calendar.ics"
}
func eventForCalendar(e *domain.Event) calendar.Event {
// We deliberately don't surface event Settings into the calendar entry —
// settings holds host-private config; the guest only needs the
// human-facing fields.
return calendar.Event{
ID: e.ID,
Name: e.Name,
Venue: e.Venue,
StartsAt: e.EventDate,
}
}
// GET /access/{token}/calendar.ics — public, token-scoped download. Same
// token-validity rules as /access/{token}, but the response is the .ics
// file rather than JSON.
func (h *tokenHandler) calendar(w http.ResponseWriter, r *http.Request) {
raw := r.PathValue("token")
if err := auth.ValidateFormat(raw); err != nil {
writeError(w, http.StatusBadRequest, "malformed token")
return
}
tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
if err != nil {
if errors.Is(err, domain.ErrTokenNotFound) {
writeError(w, http.StatusNotFound, "token not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load token")
return
}
if err := tk.IsValid(time.Now().UTC()); err != nil {
writeError(w, http.StatusGone, err.Error())
return
}
guest, err := h.guests.Get(r.Context(), tk.GuestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
event, err := h.events.Get(r.Context(), guest.EventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load event")
return
}
ics := calendar.BuildICS(eventForCalendar(event), time.Now().UTC())
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
w.Header().Set("Content-Disposition",
`attachment; filename="`+calendar.FileName(event.Name)+`"`)
w.Header().Set("Cache-Control", "private, no-store")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(ics))
}
func collectFingerprint(r *http.Request) map[string]any {
fp := map[string]any{
"user_agent": r.UserAgent(),
+204
View File
@@ -0,0 +1,204 @@
// Package calendar builds the "add to calendar" artefacts shown on the RSVP
// confirmation flow: an RFC 5545 iCalendar (.ics) file, plus the three
// well-known provider deep-links (Google, Outlook, Yahoo). Tier 2 Block B.
//
// The .ics is the source of truth — Google/Outlook/Yahoo URLs are pure URL
// builders and don't need a server round-trip, but we serve them anyway so
// the frontend doesn't have to reimplement the encoding rules per provider.
package calendar
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
// DefaultDuration is the assumed duration of an event when the host hasn't
// told us when it ends. Two hours covers most weddings, parties, and dinners
// without being so long it pollutes the guest's calendar if they don't tweak
// it themselves.
const DefaultDuration = 2 * time.Hour
// Event is the projection of domain.Event needed to render calendar artefacts.
// Kept narrow so this package doesn't depend on the broader domain model and
// can be unit-tested with synthetic data.
type Event struct {
ID uuid.UUID
Name string
Venue string
Description string
StartsAt time.Time
// EndsAt is optional. When zero, DefaultDuration is added to StartsAt.
EndsAt time.Time
}
// ProviderLinks bundles the three add-to-calendar URLs plus the path to
// download the .ics. Embedded in the /access/{token} response so the
// frontend renders four ready-made buttons.
type ProviderLinks struct {
Google string `json:"google"`
Outlook string `json:"outlook"`
Yahoo string `json:"yahoo"`
ICS string `json:"ics"`
}
// BuildLinks renders the four add-to-calendar URLs for the event. ICS is the
// publicly-reachable path on this server (typically /access/{token}/calendar.ics).
func BuildLinks(e Event, icsURL string) ProviderLinks {
start, end := e.window()
gq := url.Values{}
gq.Set("action", "TEMPLATE")
gq.Set("text", e.Name)
gq.Set("dates", fmt.Sprintf("%s/%s", fmtUTC(start), fmtUTC(end)))
if e.Description != "" {
gq.Set("details", e.Description)
}
if e.Venue != "" {
gq.Set("location", e.Venue)
}
oq := url.Values{}
oq.Set("path", "/calendar/action/compose")
oq.Set("rru", "addevent")
oq.Set("subject", e.Name)
oq.Set("startdt", start.UTC().Format(time.RFC3339))
oq.Set("enddt", end.UTC().Format(time.RFC3339))
if e.Description != "" {
oq.Set("body", e.Description)
}
if e.Venue != "" {
oq.Set("location", e.Venue)
}
yq := url.Values{}
yq.Set("v", "60")
yq.Set("title", e.Name)
yq.Set("st", fmtUTC(start))
yq.Set("et", fmtUTC(end))
if e.Description != "" {
yq.Set("desc", e.Description)
}
if e.Venue != "" {
yq.Set("in_loc", e.Venue)
}
return ProviderLinks{
Google: "https://www.google.com/calendar/render?" + gq.Encode(),
Outlook: "https://outlook.live.com/calendar/0/deeplink/compose?" + oq.Encode(),
Yahoo: "https://calendar.yahoo.com/?" + yq.Encode(),
ICS: icsURL,
}
}
// BuildICS renders a minimal RFC 5545 VCALENDAR with a single VEVENT.
// The UID is deterministic — re-downloading replaces the same calendar
// entry instead of creating a duplicate. Timestamps are emitted in UTC
// (Z-suffixed); we don't bother with VTIMEZONE blocks because every modern
// client handles UTC correctly and the host already chose a specific
// instant in `event_date`.
//
// `now` is injected for deterministic testing — pass time.Now().UTC() in
// production.
func BuildICS(e Event, now time.Time) string {
start, end := e.window()
var b strings.Builder
b.Grow(512)
write := func(lines ...string) {
for _, l := range lines {
b.WriteString(l)
b.WriteString("\r\n")
}
}
write(
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//GuestGuard//RSVP//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"BEGIN:VEVENT",
"UID:"+uidFor(e.ID),
"DTSTAMP:"+fmtUTC(now.UTC()),
"DTSTART:"+fmtUTC(start),
"DTEND:"+fmtUTC(end),
"SUMMARY:"+escape(e.Name),
)
if e.Description != "" {
write("DESCRIPTION:" + escape(e.Description))
}
if e.Venue != "" {
write("LOCATION:" + escape(e.Venue))
}
write(
"END:VEVENT",
"END:VCALENDAR",
)
return b.String()
}
// FileName slugifies an event name into something safe for the
// Content-Disposition header. Falls back to "event.ics" when nothing usable
// is left after slugification.
func FileName(name string) string {
out := make([]byte, 0, len(name))
for _, r := range strings.ToLower(name) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
out = append(out, byte(r))
case r == ' ', r == '-', r == '_':
out = append(out, '-')
}
}
// Collapse repeated dashes.
for strings.Contains(string(out), "--") {
out = []byte(strings.ReplaceAll(string(out), "--", "-"))
}
s := strings.Trim(string(out), "-")
if s == "" {
return "event.ics"
}
return s + ".ics"
}
func (e Event) window() (start, end time.Time) {
start = e.StartsAt.UTC()
if e.EndsAt.IsZero() {
end = start.Add(DefaultDuration)
} else {
end = e.EndsAt.UTC()
}
return
}
// uidFor produces a deterministic UID for an event. The @guestguard.k4scloud
// suffix is required by RFC 5545 ("globally unique identifier") and ties the
// UID to our origin so unrelated systems don't accidentally collide.
func uidFor(id uuid.UUID) string {
return id.String() + "@guestguard.k4scloud"
}
// fmtUTC encodes a time as the basic-form UTC stamp the providers expect:
// "20260517T180000Z". time.Format with a literal layout avoids the locale
// surprises of time.RFC* constants.
func fmtUTC(t time.Time) string {
return t.UTC().Format("20060102T150405Z")
}
// escape applies the small set of TEXT-value substitutions RFC 5545 calls
// out. Without these, a comma in a venue name silently truncates the field
// in some readers.
var textReplacer = strings.NewReplacer(
`\`, `\\`,
"\n", `\n`,
"\r", "",
",", `\,`,
";", `\;`,
)
func escape(s string) string {
return textReplacer.Replace(s)
}
+143
View File
@@ -0,0 +1,143 @@
package calendar
import (
"net/url"
"strings"
"testing"
"time"
"github.com/google/uuid"
)
var sampleID = uuid.MustParse("11111111-2222-3333-4444-555555555555")
func sampleEvent() Event {
return Event{
ID: sampleID,
Name: "Kwaku & Ama's Wedding",
Venue: "The Mansfield, 11 W 26th St, New York, NY",
Description: "Drinks at 6, ceremony at 7. Wear comfortable shoes; the rooftop has uneven cobbles.",
StartsAt: time.Date(2026, 6, 12, 18, 0, 0, 0, time.UTC),
}
}
func TestBuildICS_RequiredFields(t *testing.T) {
now := time.Date(2026, 5, 17, 9, 30, 0, 0, time.UTC)
got := BuildICS(sampleEvent(), now)
wantSubstrings := []string{
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//GuestGuard//RSVP//EN",
"BEGIN:VEVENT",
"UID:11111111-2222-3333-4444-555555555555@guestguard.k4scloud",
"DTSTAMP:20260517T093000Z",
"DTSTART:20260612T180000Z",
// Default 2h duration when EndsAt is zero.
"DTEND:20260612T200000Z",
// Comma + apostrophe in name; the comma is escaped, the apostrophe is not.
`SUMMARY:Kwaku & Ama's Wedding`,
"END:VEVENT",
"END:VCALENDAR",
}
for _, want := range wantSubstrings {
if !strings.Contains(got, want) {
t.Errorf("ics missing %q in output:\n%s", want, got)
}
}
}
func TestBuildICS_EscapesText(t *testing.T) {
e := sampleEvent()
now := time.Date(2026, 5, 17, 9, 30, 0, 0, time.UTC)
got := BuildICS(e, now)
// Venue has a comma — must be escaped per RFC 5545.
if !strings.Contains(got, `LOCATION:The Mansfield\, 11 W 26th St\, New York\, NY`) {
t.Errorf("expected escaped commas in LOCATION; got:\n%s", got)
}
// Description has a semicolon — also must be escaped.
if !strings.Contains(got, `DESCRIPTION:Drinks at 6\, ceremony at 7. Wear comfortable shoes\; the rooftop has uneven cobbles.`) {
t.Errorf("expected escaped semicolon in DESCRIPTION; got:\n%s", got)
}
}
func TestBuildICS_LineEndings(t *testing.T) {
got := BuildICS(sampleEvent(), time.Now().UTC())
// RFC 5545 requires CRLF line endings.
if !strings.Contains(got, "\r\n") {
t.Error("expected CRLF line endings")
}
if strings.Contains(got, "\n\n") {
t.Error("found bare LFs — must be CRLF")
}
}
func TestBuildICS_HonoursExplicitEnd(t *testing.T) {
e := sampleEvent()
e.EndsAt = time.Date(2026, 6, 12, 23, 30, 0, 0, time.UTC)
got := BuildICS(e, time.Now().UTC())
if !strings.Contains(got, "DTEND:20260612T233000Z") {
t.Errorf("explicit end not honoured: %s", got)
}
}
func TestBuildICS_OmitsEmptyOptionals(t *testing.T) {
e := Event{
ID: sampleID,
Name: "Quiet event",
StartsAt: time.Date(2026, 6, 12, 18, 0, 0, 0, time.UTC),
}
got := BuildICS(e, time.Now().UTC())
if strings.Contains(got, "LOCATION:") {
t.Error("LOCATION should be omitted when venue is empty")
}
if strings.Contains(got, "DESCRIPTION:") {
t.Error("DESCRIPTION should be omitted when description is empty")
}
}
func TestBuildLinks_GoogleEncodesDateRange(t *testing.T) {
links := BuildLinks(sampleEvent(), "https://example/access/abc/calendar.ics")
u, err := url.Parse(links.Google)
if err != nil {
t.Fatalf("parse google link: %v", err)
}
if u.Host != "www.google.com" || u.Path != "/calendar/render" {
t.Errorf("unexpected google host/path: %s%s", u.Host, u.Path)
}
dates := u.Query().Get("dates")
if dates != "20260612T180000Z/20260612T200000Z" {
t.Errorf("google dates: got %q want 20260612T180000Z/20260612T200000Z", dates)
}
if u.Query().Get("text") != "Kwaku & Ama's Wedding" {
t.Errorf("google text not preserved: %q", u.Query().Get("text"))
}
}
func TestBuildLinks_OutlookYahooShape(t *testing.T) {
links := BuildLinks(sampleEvent(), "https://example/ics")
if !strings.HasPrefix(links.Outlook, "https://outlook.live.com/calendar/0/deeplink/compose?") {
t.Errorf("outlook link wrong prefix: %s", links.Outlook)
}
if !strings.HasPrefix(links.Yahoo, "https://calendar.yahoo.com/?") {
t.Errorf("yahoo link wrong prefix: %s", links.Yahoo)
}
if links.ICS != "https://example/ics" {
t.Errorf("ICS url not echoed back: %s", links.ICS)
}
}
func TestFileName(t *testing.T) {
cases := map[string]string{
"Kwaku & Ama's Wedding": "kwaku-amas-wedding.ics",
" Birthday Party!! ": "birthday-party.ics",
"---": "event.ics",
"": "event.ics",
"Hack/Day 2026": "hackday-2026.ics",
}
for in, want := range cases {
if got := FileName(in); got != want {
t.Errorf("FileName(%q) = %q, want %q", in, got, want)
}
}
}