140 lines
8.6 KiB
Markdown
140 lines
8.6 KiB
Markdown
# add-events-ics-export
|
|
|
|
**Package**: `@suaveplan/api-plugin-events`
|
|
**Status**: PROPOSED
|
|
**Type**: feature-add (follow-up to shipped `add-api-plugin-events`)
|
|
**Spec delta**: ./specs/api-plugin-events/spec.md
|
|
|
|
## Why
|
|
|
|
The shipped `@suaveplan/api-plugin-events` (`c59bf9a03`) scoped out ICS /
|
|
iCalendar export at landing time on the grounds that the parity-migration
|
|
was already large and the export had no upstream code to migrate verbatim.
|
|
The deferral is listed explicitly in
|
|
`openspec/archive/2026-05-19-add-api-plugin-events/proposal.md` under
|
|
`## Out of scope`. Without ICS export, any consumer who wants to surface
|
|
an "Add to Calendar" button or accept a calendar-app import from one of
|
|
its events has to hand-roll the RFC 5545 builder. Hand-rolling RFC 5545
|
|
correctly is non-trivial — escape rules in §3.3.11, line folding at the
|
|
75-octet boundary in §3.1, CRLF line endings, and attendee mapping each
|
|
introduce easy-to-miss defects.
|
|
|
|
This change ships ONLY ICS export. The other items still listed in the
|
|
shipped proposal's `## Out of scope` (RRULE recurrence, notification
|
|
channel binding, calendar sync, waitlist, ticketing, SSE live updates)
|
|
remain deferred to their own follow-up changes.
|
|
|
|
## What changes
|
|
|
|
- ADDED: `GET /events/:id/ics` route — returns `text/calendar; charset=utf-8`
|
|
with a single-VEVENT VCALENDAR body for the requested event. Status,
|
|
start, end, summary, description, location, and stable UID are all
|
|
emitted from the existing event schema fields.
|
|
- ADDED: `GET /events/:id/ics?with=rsvps` variant — same VCALENDAR body
|
|
plus one `ATTENDEE` line per RSVP row. Restricted to the event creator
|
|
because the RSVP roster is private. Non-creators receive `403`.
|
|
- ADDED: `Content-Disposition: attachment; filename="event-<id>.ics"`
|
|
header on every success response.
|
|
- ADDED: RFC-5545 builder under `src/ics/ics-builder/` — pure functions:
|
|
`buildVCalendar`, `foldLine` (75-octet boundary per §3.1), `escapeText`
|
|
(§3.3.11), `formatDateUtc` (UTC `YYYYMMDDTHHMMSSZ`), `mapStatus`
|
|
(event status integer → CONFIRMED / CANCELLED / TENTATIVE),
|
|
`mapAttendeeStatus` (RSVP `going`/`maybe`/`not_going`/`invited` →
|
|
PARTSTAT `ACCEPTED`/`TENTATIVE`/`DECLINED`/`NEEDS-ACTION`).
|
|
- ADDED: ICS-export route module under `src/routes/ics-export/` —
|
|
`createIcsExportRoutes({ eventRepository, rsvpRepository, clock,
|
|
instruments })` returning an `OpenAPIHono`.
|
|
- ADDED: `EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY` — Zod boundary failure
|
|
on the `with` query parameter surfaces as HTTP `422` carrying this
|
|
code.
|
|
- ADDED: Two OTel counters on the existing instruments bundle —
|
|
`events.ics.served_total{with}` (incremented on every successful
|
|
response, labelled with `"none"` or `"rsvps"`) and
|
|
`events.ics.failed_total{reason}` (incremented on every error
|
|
response, labelled with `"not_found"`, `"forbidden"`, `"unauthenticated"`,
|
|
or `"invalid_query"`).
|
|
- ADDED: Injectable `Clock` (`@suaveplan/types`) on the ICS route
|
|
module so `DTSTAMP` is deterministic in tests; plugin defaults to
|
|
`Date.now`.
|
|
- MODIFIED: `EventsRoutesVariables` — unchanged surface, the ICS routes
|
|
reuse the same `user` slot set by the host auth middleware.
|
|
- MODIFIED: `createEventsPlugin` — `onReady` now mounts the ICS route
|
|
module alongside `createEventRoutes` under the same `basePath`.
|
|
|
|
## Source mapping
|
|
|
|
This change is a genesis-original feature; there is no upstream ICS code
|
|
in `../suaveplan/packages/server/api-plugin-events/` to migrate. The
|
|
requirement-to-source mapping is RFC-pinned, not code-pinned.
|
|
|
|
| Requirement | Source | Notes |
|
|
|---|---|---|
|
|
| `GET /events/:id/ics` returns `text/calendar; charset=utf-8` with a single VEVENT | RFC 5545 §3.4 (iCalendar object) + §3.6.1 (VEVENT component) | Body is one VCALENDAR with one VEVENT. |
|
|
| Stable `UID` per event | RFC 5545 §3.8.4.7 | `UID:${eventId}@suaveplan.events` form — deterministic from the event id. |
|
|
| `DTSTAMP` in UTC `YYYYMMDDTHHMMSSZ` form | RFC 5545 §3.8.7.2 + §3.3.5 | Sourced from injected `Clock` so tests are byte-stable. |
|
|
| `DTSTART` / `DTEND` in UTC `YYYYMMDDTHHMMSSZ` form | RFC 5545 §3.8.2.4 / §3.8.2.2 + §3.3.5 | Events on this plugin are stored in UTC per the existing service contract (event schema `timezone` defaults `"UTC"`); VTIMEZONE blocks are explicitly scoped out for v0.1. |
|
|
| `SUMMARY` set to event `title`, escaped per §3.3.11 | RFC 5545 §3.8.1.12 + §3.3.11 | `,` `;` `\` newline are backslash-escaped. |
|
|
| `DESCRIPTION` set to event `description` if present, escaped per §3.3.11 | RFC 5545 §3.8.1.5 + §3.3.11 | Same escape rules as SUMMARY. |
|
|
| `LOCATION` set to event `location.name` if present, escaped | RFC 5545 §3.8.1.7 + §3.3.11 | Free-form text. Address / coordinates not included in v0.1. |
|
|
| `STATUS` mapped from event status integer (`0`→`TENTATIVE`, `1`→`CONFIRMED`, `2`→`CANCELLED`) | RFC 5545 §3.8.1.11 | `TENTATIVE` / `CONFIRMED` / `CANCELLED` are the only VEVENT-valid values. |
|
|
| `SEQUENCE:0` always emitted (event schema has no revision counter) | RFC 5545 §3.8.7.4 | Explicitly fixed at `0` until a revision field is added. |
|
|
| Line folding at 75 octets per §3.1 (continuation lines start with one space) | RFC 5545 §3.1 | Byte-level fold; long DESCRIPTION lines are folded inside the builder. |
|
|
| CRLF (`\r\n`) line endings throughout | RFC 5545 §3.1 | Every output line including the final BEGIN/END terminators. |
|
|
| `?with=rsvps` adds one `ATTENDEE` line per RSVP, with `CN` from `user_name`, `PARTSTAT` from the RSVP `status`, and the user id encoded as `mailto:<id>@suaveplan.local` when `user_email` is absent | RFC 5545 §3.8.4.1 (ATTENDEE) + §3.3.3 (CAL-ADDRESS) | `mailto:` placeholder satisfies CAL-ADDRESS while preserving the user id; the `@suaveplan.local` suffix is non-routable. |
|
|
| `?with=rsvps` requires creator (403 for non-creators) | Genesis security convention | RSVP roster is private; only the event owner may export it. |
|
|
| `Content-Disposition: attachment; filename="event-<id>.ics"` | RFC 6266 §4 (Content-Disposition) | Forces calendar-app download instead of inline render. |
|
|
| Typed `EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY` for malformed `with` | AGENTS.md §2c | New code rather than reusing an unrelated existing code; thrown by ≥1 prod path (Zod boundary handler). |
|
|
| OTel counters `events.ics.served_total{with}` + `events.ics.failed_total{reason}` | CLAUDE.md "Observability" + genesis-wide policy | Real in-memory `MeterProvider` asserts emission. |
|
|
|
|
## Out of scope
|
|
|
|
The shipped proposal's full `## Out of scope` list is preserved unchanged
|
|
for the items NOT being addressed here:
|
|
|
|
- RRULE recurrence / RFC 5545 `RRULE` expansion — deferred to
|
|
`add-api-plugin-events-recurrence`.
|
|
- VTIMEZONE blocks — events are stored UTC; tzid emission lands when
|
|
`@suaveplan/datetime` adds the canonical IANA-to-VTIMEZONE mapper.
|
|
- Calendar-feed subscriptions (`text/calendar` polled at a stable URL
|
|
with an `Etag`), push delivery via WebDAV / CalDAV, ticketing
|
|
payloads (`X-WR-*` Apple extensions, structured `ATTACH`).
|
|
- Notification fan-out via `@suaveplan/notification-channel` —
|
|
unchanged from the shipped proposal.
|
|
- Rate-limit / audit-log on the ICS endpoint — composed at the host
|
|
level once `api-plugin-rate-limit` / `api-plugin-audit` ship.
|
|
|
|
## Verification approach
|
|
|
|
- `src/ics/ics-builder/ics-builder.test.ts` — byte-level builder tests:
|
|
fold at 75 octets, escape `,` `;` `\` newline, CRLF endings, UTC date
|
|
format, status mapping, attendee PARTSTAT mapping, RSVP variant body.
|
|
- `src/routes/ics-export/ics-export.test.ts` — real Hono request: 200
|
|
on success, 401 on missing user, 404 on missing event, 403 when a
|
|
non-creator requests `?with=rsvps`, 422 on invalid `with` value,
|
|
`Content-Type` and `Content-Disposition` header asserts, OTel
|
|
counter assertions via real in-memory `MeterProvider`.
|
|
- `src/otel/instruments.test.ts` — extended to cover the two new
|
|
counters.
|
|
- `src/error-codes/error-codes.test.ts` — extended to cover the new
|
|
`ICS_INVALID_QUERY` code reachability.
|
|
- `src/api-plugin-events.spec-conformance.test.ts` — pins the new
|
|
public symbols `createIcsExportRoutes` + `buildVCalendar`.
|
|
|
|
## Migration impact
|
|
|
|
Additive only on the public surface. Consumers who already mount
|
|
`createEventsPlugin()` automatically gain the `GET /events/:id/ics`
|
|
endpoint on their existing `basePath`. No breaking changes to existing
|
|
routes, schemas, or error codes.
|
|
|
|
## Impact
|
|
|
|
### Affected dependents
|
|
|
|
Run `bunx turbo ls --filter=...@suaveplan/api-plugin-events` to enumerate.
|
|
|
|
- No `@suaveplan/*` dependents exist on genesis today (verified by
|
|
`bunx turbo ls --filter=...@suaveplan/api-plugin-events` — zero
|
|
matches). Downstream apps that already mount the plugin gain the
|
|
new endpoint automatically.
|