// 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) }