Initial Commit
This commit is contained in:
139
openspec/archive/2026-05-20-add-events-ics-export/proposal.md
Normal file
139
openspec/archive/2026-05-20-add-events-ics-export/proposal.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,175 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: api-plugin-events (add-events-ics-export)
|
||||
|
||||
**Package**: `@suaveplan/api-plugin-events`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at draft time.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ICS export endpoint
|
||||
|
||||
The package SHALL expose a `GET /:id/ics` route mounted by `createEventsPlugin` under the configured `basePath`. The route SHALL return HTTP `200` with `Content-Type: text/calendar; charset=utf-8` and a body containing one RFC 5545 `VCALENDAR` block enclosing a single `VEVENT` for the requested event. The response SHALL carry `Content-Disposition: attachment; filename="event-<id>.ics"` where `<id>` is the URL-safe event id. Authentication SHALL be required: a request without `c.get("user")` SHALL return HTTP `401` with body `{ error: "Authentication required", code: "EVENTS_PLUGIN.UNAUTHENTICATED" }`. A request whose event id does not resolve in `eventRepository.findById` SHALL return HTTP `404` with code `EVENTS_PLUGIN.EVENT_NOT_FOUND`.
|
||||
|
||||
#### Scenario: unauthenticated request returns 401
|
||||
|
||||
- **WHEN** `GET /event:1/ics` runs without `user` set in the Hono context
|
||||
- **THEN** the response status is `401` and the body contains `code: "EVENTS_PLUGIN.UNAUTHENTICATED"`
|
||||
|
||||
#### Scenario: 200 success returns VCALENDAR with single VEVENT
|
||||
|
||||
- **WHEN** `GET /event:1/ics` runs with `user` set and `eventRepository.findById` returns an event
|
||||
- **THEN** the response status is `200`, `Content-Type` is `text/calendar; charset=utf-8`, `Content-Disposition` is `attachment; filename="event-event:1.ics"`, and the body matches `/^BEGIN:VCALENDAR\r\n[\s\S]+\r\nEND:VCALENDAR\r\n$/` containing exactly one `BEGIN:VEVENT` / `END:VEVENT` pair
|
||||
|
||||
#### Scenario: missing event returns 404
|
||||
|
||||
- **WHEN** `GET /missing/ics` runs and `findById` returns `null`
|
||||
- **THEN** the response status is `404` and the body carries `code: "EVENTS_PLUGIN.EVENT_NOT_FOUND"`
|
||||
|
||||
**Verified by:** `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 401 without user"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 200 with single VEVENT VCALENDAR"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 404 when event missing"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics sets Content-Disposition attachment header"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: VEVENT body content
|
||||
|
||||
The single `VEVENT` block SHALL emit the following properties, sourced from the event fields shipped on `EventSchema`: `UID` as `${eventId}@suaveplan.events`; `DTSTAMP` from the injected `Clock` formatted as UTC `YYYYMMDDTHHMMSSZ` per §3.3.5; `DTSTART` from `event.start_time` formatted as UTC `YYYYMMDDTHHMMSSZ`; `DTEND` from `event.end_time` formatted the same way; `SUMMARY` from `event.title` with §3.3.11 escaping applied; `DESCRIPTION` from `event.description` if present, also escaped; `LOCATION` from `event.location.name` if present, also escaped; `STATUS` mapped from the event status integer (`0`→`TENTATIVE`, `1`→`CONFIRMED`, `2`→`CANCELLED`) per §3.8.1.11; and `SEQUENCE:0`. `ORGANIZER` SHALL NOT be emitted in v0.1 because the event schema does not carry a host email — the empty slot is documented in the proposal and remains reserved for a future schema field.
|
||||
|
||||
#### Scenario: every required property is present in order
|
||||
|
||||
- **WHEN** the builder runs against an event with title `"T"`, description `"d"`, location name `"L"`, start `2026-01-01T10:00:00Z`, end `2026-01-01T12:00:00Z`, status `1`
|
||||
- **THEN** the body contains exactly one of each line in order: `BEGIN:VEVENT`, `UID:event:1@suaveplan.events`, `DTSTAMP:...Z`, `DTSTART:20260101T100000Z`, `DTEND:20260101T120000Z`, `SUMMARY:T`, `DESCRIPTION:d`, `LOCATION:L`, `STATUS:CONFIRMED`, `SEQUENCE:0`, `END:VEVENT`
|
||||
|
||||
#### Scenario: status maps draft → TENTATIVE and cancelled → CANCELLED
|
||||
|
||||
- **WHEN** the builder runs with `status: 0` and again with `status: 2`
|
||||
- **THEN** the first body contains `STATUS:TENTATIVE` and the second contains `STATUS:CANCELLED`
|
||||
|
||||
#### Scenario: optional fields omit when absent
|
||||
|
||||
- **WHEN** the builder runs with `description` undefined and `location` undefined
|
||||
- **THEN** the body contains no `DESCRIPTION:` or `LOCATION:` line
|
||||
|
||||
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"emits every required VEVENT property in canonical order"` AND `src/ics/ics-builder/ics-builder.test.ts::"maps status integer to RFC 5545 STATUS value"` AND `src/ics/ics-builder/ics-builder.test.ts::"omits DESCRIPTION and LOCATION when absent"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Text escape rules per §3.3.11
|
||||
|
||||
Every value written into `SUMMARY`, `DESCRIPTION`, or `LOCATION` SHALL be escaped per RFC 5545 §3.3.11: backslash → `\\`, semicolon → `\;`, comma → `\,`, newline (`\n` and `\r\n`) → `\n` (the literal two characters `\` + `n`). Carriage returns inside text SHALL be normalised away before escaping. Escaping SHALL happen before line folding.
|
||||
|
||||
#### Scenario: comma semicolon backslash newline are escaped
|
||||
|
||||
- **WHEN** `escapeText("a,b;c\\d\ne")` runs
|
||||
- **THEN** the result is the exact string `a\,b\;c\\d\ne`
|
||||
|
||||
#### Scenario: CRLF in source becomes a single escaped \n
|
||||
|
||||
- **WHEN** `escapeText("a\r\nb")` runs
|
||||
- **THEN** the result is the exact string `a\nb`
|
||||
|
||||
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"escapeText escapes comma semicolon backslash and newline"` AND `src/ics/ics-builder/ics-builder.test.ts::"escapeText normalises CRLF to a single \\n"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Line folding per §3.1
|
||||
|
||||
Every emitted content line longer than 75 octets SHALL be folded: the line is split into 75-octet segments and each subsequent segment is prefixed with a single SPACE on a new CRLF-terminated line, exactly as specified in RFC 5545 §3.1. Folding SHALL operate on the post-escape byte stream so multi-byte UTF-8 sequences are not split mid-codepoint when avoidable (the implementation MAY fold at the nearest safe byte boundary ≤75 octets to preserve UTF-8). Every output line including the final `END:VCALENDAR` SHALL terminate with CRLF (`\r\n`).
|
||||
|
||||
#### Scenario: a 200-character DESCRIPTION folds into three CRLF-separated segments
|
||||
|
||||
- **WHEN** the builder runs against an event whose escaped `DESCRIPTION` is 200 ASCII octets long
|
||||
- **THEN** the body contains `DESCRIPTION:` followed by the first 63 octets on the first physical line (75 = `DESCRIPTION:` 12 octets + 63 payload), then `\r\n` (CRLF + space) before each subsequent 74-octet payload segment, and every physical line ends in `\r\n`
|
||||
|
||||
#### Scenario: every output line ends in CRLF
|
||||
|
||||
- **WHEN** `buildVCalendar` runs against any valid input
|
||||
- **THEN** the body contains zero `\n` bytes that are not preceded by `\r`
|
||||
|
||||
**Verified by:** `src/ics/ics-builder/ics-builder.test.ts::"folds a 200-octet DESCRIPTION into 75-octet continuation segments"` AND `src/ics/ics-builder/ics-builder.test.ts::"every output line ends in CRLF"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RSVP attendee export (?with=rsvps)
|
||||
|
||||
When the request includes `?with=rsvps`, the response body SHALL contain one `ATTENDEE` line inside the `VEVENT` per row returned by `rsvpRepository.findAttendees(eventId)`. Each `ATTENDEE` line SHALL carry the `CN` parameter from the attendee's `user_name` if present (escaped per §3.3.11), a `PARTSTAT` parameter mapped from the RSVP status (`going`→`ACCEPTED`, `maybe`→`TENTATIVE`, `not_going`→`DECLINED`, `invited`→`NEEDS-ACTION`), and the CAL-ADDRESS value as `mailto:<user_email>` when `user_email` is present or `mailto:<user_id>@suaveplan.local` as a non-routable fallback when it is not. The `?with=rsvps` variant SHALL require the requester to be the event creator: a non-creator SHALL receive HTTP `403` with code `EVENTS_PLUGIN.FORBIDDEN`. The plain `GET /:id/ics` (no `with` query) SHALL NOT emit any `ATTENDEE` lines and SHALL NOT require creator privileges.
|
||||
|
||||
#### Scenario: with=rsvps emits one ATTENDEE per RSVP
|
||||
|
||||
- **WHEN** `GET /event:1/ics?with=rsvps` runs with `user` = creator and `findAttendees` returns 2 RSVP rows
|
||||
- **THEN** the body contains exactly 2 `ATTENDEE` lines, each carrying a `PARTSTAT=` parameter and a `mailto:` value
|
||||
|
||||
#### Scenario: with=rsvps maps PARTSTAT values
|
||||
|
||||
- **WHEN** the builder runs with attendees of status `going`, `maybe`, `not_going`, `invited`
|
||||
- **THEN** the corresponding ATTENDEE lines carry `PARTSTAT=ACCEPTED`, `PARTSTAT=TENTATIVE`, `PARTSTAT=DECLINED`, `PARTSTAT=NEEDS-ACTION`
|
||||
|
||||
#### Scenario: with=rsvps by non-creator returns 403
|
||||
|
||||
- **WHEN** `GET /event:1/ics?with=rsvps` runs with a user where `isCreator` returns `false`
|
||||
- **THEN** the response status is `403` and the body carries `code: "EVENTS_PLUGIN.FORBIDDEN"`
|
||||
|
||||
#### Scenario: invalid with value returns 422
|
||||
|
||||
- **WHEN** `GET /event:1/ics?with=bogus` runs
|
||||
- **THEN** the response status is `422` and the body carries `code: "EVENTS_PLUGIN.ICS_INVALID_QUERY"`
|
||||
|
||||
**Verified by:** `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics?with=rsvps emits one ATTENDEE per RSVP"` AND `src/ics/ics-builder/ics-builder.test.ts::"maps RSVP status to ATTENDEE PARTSTAT"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics?with=rsvps returns 403 for non-creator"` AND `src/routes/ics-export/ics-export.test.ts::"GET /:id/ics returns 422 for invalid with value"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: New error code
|
||||
|
||||
The package SHALL extend `EVENTS_PLUGIN_CODES` with `ICS_INVALID_QUERY` (registry size 12 → 13). The code SHALL be raised when the Zod schema for the ICS route query rejects an unknown `with` value, surfaced to the client as HTTP `422`. The code SHALL be thrown by at least one production path under `src/routes/ics-export/`.
|
||||
|
||||
#### Scenario: ICS_INVALID_QUERY is reachable
|
||||
|
||||
- **WHEN** the error-codes conformance test reflects over `EVENTS_PLUGIN_CODES` and greps `src/` for a `new EventsPluginError(EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY, ...)` or `code: EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY` call site
|
||||
- **THEN** at least one match is found
|
||||
|
||||
**Verified by:** `src/error-codes/error-codes.test.ts::"every EVENTS_PLUGIN_CODES value is thrown by src/"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OTel counters
|
||||
|
||||
The package SHALL extend `EventsInstruments` with two new counters created against `metrics.getMeter("@suaveplan/api-plugin-events")`: `icsServedTotal` (instrument name `events.ics.served_total`, labelled with `with` taking values `"none"` or `"rsvps"`) and `icsFailedTotal` (instrument name `events.ics.failed_total`, labelled with `reason` taking values `"not_found"`, `"forbidden"`, `"unauthenticated"`, or `"invalid_query"`). Both counters SHALL be lazily built, cached, and cleared by `resetEventsInstruments()`. The ICS route SHALL increment `icsServedTotal` on every successful response and `icsFailedTotal` on every error response.
|
||||
|
||||
#### Scenario: success path increments served_total
|
||||
|
||||
- **WHEN** a freshly-installed in-memory `MeterProvider` is set, `resetEventsInstruments()` is called, then `GET /event:1/ics` returns 200
|
||||
- **THEN** the next `forceFlush` of the in-memory reader contains `events.ics.served_total` with attribute `with=none` and value `1`
|
||||
|
||||
#### Scenario: 404 path increments failed_total
|
||||
|
||||
- **WHEN** the same setup runs and `GET /missing/ics` returns 404
|
||||
- **THEN** the next `forceFlush` contains `events.ics.failed_total` with attribute `reason=not_found` and value `1`
|
||||
|
||||
**Verified by:** `src/otel/instruments.test.ts::"exposes ics served and failed counters"` AND `src/routes/ics-export/ics-export.test.ts::"increments events.ics.served_total on success"` AND `src/routes/ics-export/ics-export.test.ts::"increments events.ics.failed_total on 404"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Public surface (subpath exports + named re-exports)
|
||||
|
||||
The package SHALL add two new subpath exports in `package.json`: `./ics/ics-builder/ics-builder` and `./routes/ics-export/ics-export`. The root barrel SHALL add explicit named re-exports of `createIcsExportRoutes`, `buildVCalendar`, `BuildVCalendarInput`, `IcsAttendee`, `IcsExportRoutesConfig`, and `IcsEventStatus` (rule 10 — no `export *` outside generated forwarders).
|
||||
|
||||
#### Scenario: new symbols resolve from the package root
|
||||
|
||||
- **WHEN** the conformance test imports the package root and asserts the new symbols resolve to non-`undefined`
|
||||
- **THEN** every expected symbol matches
|
||||
|
||||
**Verified by:** `src/api-plugin-events.spec-conformance.test.ts::"re-exports ICS surface (builder + routes)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Plugin wires ICS routes alongside CRUD routes
|
||||
|
||||
`createEventsPlugin`'s `onReady` SHALL mount `createIcsExportRoutes(...)` on the same `OpenAPIHono` instance under the same `basePath` as `createEventRoutes(...)`. Both route modules SHALL share the host-supplied `user` context variable. The plugin SHALL pass `Date.now` as the default `Clock` to the ICS routes; tests MAY override via `createIcsExportRoutes` directly.
|
||||
|
||||
#### Scenario: plugin onReady mounts an ICS route
|
||||
|
||||
- **WHEN** `onReady` runs against a fresh registry and app
|
||||
- **THEN** the app's `routes` array contains at least one entry whose `path` ends in `/:id/ics`
|
||||
|
||||
**Verified by:** `src/plugin/plugin.test.ts::"onReady mounts the ICS export route"`
|
||||
67
openspec/archive/2026-05-20-add-events-ics-export/tasks.md
Normal file
67
openspec/archive/2026-05-20-add-events-ics-export/tasks.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Tasks — add-events-ics-export
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [ ] 1.1 Confirm shipped `openspec/specs/api-plugin-events/spec.md` is the canonical baseline (already folded by `add-api-plugin-events` archive)
|
||||
- [ ] 1.2 Author `proposal.md` with `## Why`, `## What changes`, `## Source mapping` (each row citing RFC 5545 section or existing event-schema field)
|
||||
- [ ] 1.3 Author `specs/api-plugin-events/spec.md` with `## ADDED Requirements` for the ICS endpoint, escape rules, line-folding, RSVP attendees, new error code, OTel counters
|
||||
- [ ] 1.4 Every `### Requirement` carries a `**Verified by:**` line naming planned test files (authored at draft time per §0c)
|
||||
|
||||
## 2. Implementation — ICS builder
|
||||
|
||||
- [ ] 2.1 `src/ics/ics-builder/ics-builder.ts` — `buildVCalendar`, `foldLine`, `escapeText`, `formatDateUtc`, `mapStatus`, `mapAttendeeStatus`
|
||||
- [ ] 2.2 `src/ics/ics-builder/ics-builder.types.ts` — public types (`BuildVCalendarInput`, `IcsAttendee`, `IcsEventStatus`)
|
||||
- [ ] 2.3 `src/ics/ics-builder/ics-builder.md` — ≥200-word sibling doc (Purpose / Features / Basic / Advanced / API Reference / Implementation Notes)
|
||||
- [ ] 2.4 `src/ics/ics-builder/ics-builder.test.ts` — byte-level fold, escape, CRLF, UTC date format, status / PARTSTAT maps, with-rsvps body
|
||||
- [ ] 2.5 `src/ics/ics-builder/index.ts` — explicit named re-exports
|
||||
|
||||
## 3. Implementation — error code, OTel, routes
|
||||
|
||||
- [ ] 3.1 Extend `EVENTS_PLUGIN_CODES` with `ICS_INVALID_QUERY`
|
||||
- [ ] 3.2 Extend `EventsInstruments` with `icsServedTotal` + `icsFailedTotal`
|
||||
- [ ] 3.3 `src/routes/ics-export/ics-export.ts` — `createIcsExportRoutes({ eventRepository, rsvpRepository, clock, instruments? })`
|
||||
- [ ] 3.4 `src/routes/ics-export/ics-export.types.ts` — `IcsExportRoutesConfig`
|
||||
- [ ] 3.5 `src/routes/ics-export/ics-export.md` — ≥200-word sibling doc
|
||||
- [ ] 3.6 `src/routes/ics-export/ics-export.test.ts` — 200 / 401 / 404 / 403 / 422 paths, header asserts, OTel asserts
|
||||
- [ ] 3.7 `src/routes/ics-export/index.ts` — explicit named re-exports
|
||||
|
||||
## 4. Wire-up
|
||||
|
||||
- [ ] 4.1 `plugin.ts` `onReady` mounts `createIcsExportRoutes` alongside `createEventRoutes` under the same `basePath`
|
||||
- [ ] 4.2 `plugin.test.ts` extended to assert the ICS route is mounted
|
||||
- [ ] 4.3 `src/index.ts` re-exports `createIcsExportRoutes`, `buildVCalendar`, `BuildVCalendarInput`, `IcsAttendee`, `IcsExportRoutesConfig`, `IcsEventStatus`
|
||||
- [ ] 4.4 `package.json` — add `./ics/ics-builder/ics-builder` and `./routes/ics-export/ics-export` subpath exports
|
||||
- [ ] 4.5 `README.md` — add Modules entries; add ICS quickstart snippet
|
||||
|
||||
## 5. Tests
|
||||
|
||||
- [ ] 5.1 `src/error-codes/error-codes.test.ts` — pin `ICS_INVALID_QUERY` is reachable from a real `throw`/`return` site
|
||||
- [ ] 5.2 `src/otel/instruments.test.ts` — extend with `icsServedTotal` + `icsFailedTotal` assertions
|
||||
- [ ] 5.3 `src/api-plugin-events.spec-conformance.test.ts` — pin `createIcsExportRoutes`, `buildVCalendar` re-exports
|
||||
- [ ] 5.4 Every new `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
|
||||
|
||||
## 6. Pre-archive verification — 5-gate triple
|
||||
|
||||
- [ ] 6.1 `bunx biome check src/` — clean
|
||||
- [ ] 6.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-plugin-events` — green
|
||||
- [ ] 6.3 `bunx turbo test:coverage --filter=@suaveplan/api-plugin-events` — 100 / 100 / 100 / 100
|
||||
- [ ] 6.4 `bun scripts/openspec-conformance.ts @suaveplan/api-plugin-events` — 0 errors
|
||||
- [ ] 6.5 `bunx turbo build --filter=@suaveplan/api-plugin-events` — green
|
||||
|
||||
## 7. Pre-archive 9-row checklist (AGENTS.md §13)
|
||||
|
||||
- [ ] 7.1 Spec reconciliation — every `### Requirement` walked, every `**Verified by:**` test exists
|
||||
- [ ] 7.2 Banned-pattern scan — zero `TODO|FIXME|ts-ignore|ts-expect-error|.skip|.only`
|
||||
- [ ] 7.3 Barrel-shape scan — no `export *` in feature `index.ts`
|
||||
- [ ] 7.4 Test-runner imports — every test file imports only from `@suaveplan/testing/runner`
|
||||
- [ ] 7.5 Missing `.md` scan — every implementation `.ts` has a sibling `.md`
|
||||
- [ ] 7.6 Bare version check — no bare semver in `package.json`
|
||||
- [ ] 7.7 Subpath export count — matches feature-module count
|
||||
- [ ] 7.8 Security scan — no hardcoded secrets
|
||||
- [ ] 7.9 No biome-ignore suppressions in new code
|
||||
|
||||
## 8. Archive
|
||||
|
||||
- [ ] 8.1 Fold spec delta into `openspec/specs/api-plugin-events/spec.md`
|
||||
- [ ] 8.2 Move `openspec/changes/add-events-ics-export/` → `openspec/archive/2026-05-20-add-events-ics-export/`
|
||||
- [ ] 8.3 One commit `feat(api-plugin-events): add ICS export endpoint (was §4-deferred)` — pre-pull-rebase first, never `--no-verify`, never force-push
|
||||
Reference in New Issue
Block a user