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(),