6803d700b4
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>
205 lines
5.8 KiB
Go
205 lines
5.8 KiB
Go
// 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)
|
|
}
|