Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
# @suaveplan/api-plugin-events - Event-management plugin for @suaveplan/api. Ships a SurrealDB-backed EventRepository + RsvpRepository pair, 10 Hono OpenAPI routes (list / get / create / update / delete / rsvp / cancel-rsvp / my-rsvp / attendees / invite / ics export), 13 EVENTS_PLUGIN_* error codes, 7 OTel instruments, and a slimmed-down surql migration for the event / event_rsvp tables. Server-tier — runs in Bun and Node.
Event-management plugin for `@suaveplan/api`. Ships a SurrealDB-backed
`EventRepository` + `RsvpRepository` pair, ten Hono OpenAPI routes
(list / get / create / update / delete / rsvp / cancel-rsvp / my-rsvp
/ attendees / invite / **ics export**), thirteen `EVENTS_PLUGIN_*`
error codes, seven OTel counters, and a slim surql migration covering
the `event` and `event_rsvp` tables. The ICS export endpoint emits a
hand-rolled RFC 5545 `text/calendar` body — no third-party `ical`
dependency — with full §3.3.11 escapes and §3.1 line folding.
This plugin is for **event entities** (meetups, invitations, RSVPs,
attendee tracking). It is NOT the typed event-bus library — that is
`@suaveplan/events`, which this plugin happens to consume.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and concrete error class for `@suaveplan/api-plugin-events`. Every failure path…
- [error-codes](./src/error-codes/error-codes.md) — Defines the stable error-code registry and concrete error class for `@suaveplan/api-plugin-events`. Every failure path…
- [ics-builder](./src/ics/ics-builder/ics-builder.md) — The `ics-builder` module emits a complete RFC 5545 `VCALENDAR` document for a single event document loaded from `EventR…
- [ics-builder](./src/ics/ics-builder/ics-builder.md) — The `ics-builder` module emits a complete RFC 5545 `VCALENDAR` document for a single event document loaded from `EventR…
- [otel](./src/otel/instruments.md) — Bundles the seven OpenTelemetry counters emitted by `@suaveplan/api-plugin-events`. Every counter is lazily built on th…
- [instruments](./src/otel/instruments.md) — Bundles the seven OpenTelemetry counters emitted by `@suaveplan/api-plugin-events`. Every counter is lazily built on th…
- [plugin](./src/plugin/plugin.md) — Defines `createEventsPlugin`, the factory that returns the `PluginDescriptor<EventsPluginConfig>` consumed by `@suavepl…
- [plugin](./src/plugin/plugin.md) — Defines `createEventsPlugin`, the factory that returns the `PluginDescriptor<EventsPluginConfig>` consumed by `@suavepl…
- [event-repository](./src/repositories/event-repository/event-repository.md) — SurrealDB-backed implementation of the `IEventRepository` contract. The repository owns event-entity CRUD: list / get /…
- [event-repository](./src/repositories/event-repository/event-repository.md) — SurrealDB-backed implementation of the `IEventRepository` contract. The repository owns event-entity CRUD: list / get /…
- [rsvp-repository](./src/repositories/rsvp-repository/rsvp-repository.md) — SurrealDB-backed implementation of the `IRsvpRepository` contract. The repository owns RSVP / invitation persistence: p…
- [rsvp-repository](./src/repositories/rsvp-repository/rsvp-repository.md) — SurrealDB-backed implementation of the `IRsvpRepository` contract. The repository owns RSVP / invitation persistence: p…
- [events-routes](./src/routes/events-routes/events-routes.md) — Builds the nine-route OpenAPI Hono module mounted by `createEventsPlugin`. Each route is wrapped by `@hono/zod-openapi`…
- [events-routes](./src/routes/events-routes/events-routes.md) — Builds the nine-route OpenAPI Hono module mounted by `createEventsPlugin`. Each route is wrapped by `@hono/zod-openapi`…
- [ics-export](./src/routes/ics-export/ics-export.md) — The `ics-export` route module exposes `GET /:id/ics` on top of the shared events `OpenAPIHono` mount, returning an RFC…
- [ics-export](./src/routes/ics-export/ics-export.md) — The `ics-export` route module exposes `GET /:id/ics` on top of the shared events `OpenAPIHono` mount, returning an RFC…
- [event-schemas](./src/schemas/event-schemas.md) — The package-local Zod schemas for the `event` entity surface. Owns every input / output shape touching the events route…
- [options](./src/schemas/options.md) — The Zod schema for `createEventsPlugin`'s options bundle. Three fields: `basePath` (mount prefix, default `/events`), `…
- [rsvp-schemas](./src/schemas/rsvp-schemas.md) — Local Zod schemas for the RSVP / invitation surface. Owns the persisted `RsvpSchema`, the `CreateRsvpSchema` request bo…
- [services](./src/services/registry-augmentation.md) — Module-augmentation file that adds the `eventRepository` and `rsvpRepository` slots to `@suaveplan/api-plugin/service-r…
- [registry-augmentation](./src/services/registry-augmentation.md) — Module-augmentation file that adds the `eventRepository` and `rsvpRepository` slots to `@suaveplan/api-plugin/service-r…
- [types](./src/types/types.md) — Public type definitions that don't live alongside their schemas or implementations: the cross-plugin `EventsPluginEvent…
- [types](./src/types/types.md) — Public type definitions that don't live alongside their schemas or implementations: the cross-plugin `EventsPluginEvent…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/api-plugin-events
```
## Quick start
```ts
import { quickstart } from "@suaveplan/api";
import { createDatabasePlugin } from "@suaveplan/api-plugin-database";
import { createEventsPlugin } from "@suaveplan/api-plugin-events";
const app = await quickstart({
plugins: [
createDatabasePlugin({
url: "ws://localhost:8000/rpc",
namespace: "app",
database: "main",
username: "root",
password: "root",
}),
createEventsPlugin({
basePath: "/events",
enableNotifications: false,
maxInvitesPerRequest: 50,
}),
],
});
// Subscribe to domain events on the shared bus
app.eventBus.on("event:created", ({ eventId, creatorId }) => {
console.log("event", eventId, "created by", creatorId);
});
```
## Modules
- [error-codes/error-codes](./src/error-codes/error-codes.md) — 12-code registry + `EventsPluginError`.
- [otel/instruments](./src/otel/instruments.md) — 5 OTel counters.
- [plugin/plugin](./src/plugin/plugin.md) — `createEventsPlugin` factory + lifecycle.
- [repositories/event-repository/event-repository](./src/repositories/event-repository/event-repository.md) — `EventRepository`.
- [repositories/rsvp-repository/rsvp-repository](./src/repositories/rsvp-repository/rsvp-repository.md) — `RsvpRepository`.
- [routes/events-routes/events-routes](./src/routes/events-routes/events-routes.md) — `createEventRoutes` (9 endpoints).
- [routes/ics-export/ics-export](./src/routes/ics-export/ics-export.md) — `createIcsExportRoutes` (RFC 5545 ICS export).
- [ics/ics-builder/ics-builder](./src/ics/ics-builder/ics-builder.md) — hand-rolled VCALENDAR builder.
- [schemas/event-schemas](./src/schemas/event-schemas.md) — local Zod schemas for events.
- [schemas/rsvp-schemas](./src/schemas/rsvp-schemas.md) — local Zod schemas for RSVPs.
- [schemas/options](./src/schemas/options.md) — `EventsPluginOptionsSchema`.
- [services/registry-augmentation](./src/services/registry-augmentation.md) — `ServiceRegistry` slots.
- [types/types](./src/types/types.md) — `EventsPluginEvents` + `EventsRouteEvent`.
## Out of scope
See the design proposal
for the full list. Highlights:
- ICS / iCalendar export — shipped by `add-events-ics-export` (2026-05-20).
- Recurring events (RFC-5545 `RRULE`) — deferred to `add-api-plugin-events-recurrence`.
- Notification fan-out — `enableNotifications` is reserved; binding lands when `@suaveplan/notification-channel` migrates.
- Rate-limit + audit-log integration — composed at the host level once `api-plugin-rate-limit` and `api-plugin-audit` land.
- Upstream surql cross-plugin permission rules, helper functions, and the `event_update` table — scoped out because they depend on plugins (site, group, notifications, bitwise) not yet .
## Status
`0.1.0` — feature parity with upstream `@suaveplan/api-plugin-events`
modulo the explicit scope-outs above. 100% line / branch / function /
statement coverage. Real SurrealDB testcontainer integration.

View File

@@ -0,0 +1,140 @@
{
"name": "@suaveplan/api-plugin-events",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Event-management plugin for @suaveplan/api. Ships a SurrealDB-backed EventRepository + RsvpRepository pair, 10 Hono OpenAPI routes (list / get / create / update / delete / rsvp / cancel-rsvp / my-rsvp / attendees / invite / ics export), 13 EVENTS_PLUGIN_* error codes, 7 OTel instruments, and a slimmed-down surql migration for the event / event_rsvp tables. Server-tier — runs in Bun and Node.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./plugin/plugin": {
"import": {
"types": "./dist/plugin/plugin.d.ts",
"default": "./dist/plugin/plugin.js"
}
},
"./schemas/options": {
"import": {
"types": "./dist/schemas/options.d.ts",
"default": "./dist/schemas/options.js"
}
},
"./schemas/event-schemas": {
"import": {
"types": "./dist/schemas/event-schemas.d.ts",
"default": "./dist/schemas/event-schemas.js"
}
},
"./schemas/rsvp-schemas": {
"import": {
"types": "./dist/schemas/rsvp-schemas.d.ts",
"default": "./dist/schemas/rsvp-schemas.js"
}
},
"./error-codes/error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./otel/instruments": {
"import": {
"types": "./dist/otel/instruments.d.ts",
"default": "./dist/otel/instruments.js"
}
},
"./repositories/event-repository/event-repository": {
"import": {
"types": "./dist/repositories/event-repository/event-repository.d.ts",
"default": "./dist/repositories/event-repository/event-repository.js"
}
},
"./repositories/rsvp-repository/rsvp-repository": {
"import": {
"types": "./dist/repositories/rsvp-repository/rsvp-repository.d.ts",
"default": "./dist/repositories/rsvp-repository/rsvp-repository.js"
}
},
"./routes/events-routes/events-routes": {
"import": {
"types": "./dist/routes/events-routes/events-routes.d.ts",
"default": "./dist/routes/events-routes/events-routes.js"
}
},
"./routes/ics-export/ics-export": {
"import": {
"types": "./dist/routes/ics-export/ics-export.d.ts",
"default": "./dist/routes/ics-export/ics-export.js"
}
},
"./ics/ics-builder/ics-builder": {
"import": {
"types": "./dist/ics/ics-builder/ics-builder.d.ts",
"default": "./dist/ics/ics-builder/ics-builder.js"
}
},
"./types/types": {
"import": {
"types": "./dist/types/types.d.ts",
"default": "./dist/types/types.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@hono/zod-openapi": "catalog:server",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api": "workspace:^",
"@suaveplan/api-plugin": "workspace:^",
"@suaveplan/api-plugin-database": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/events": "workspace:^",
"@suaveplan/surrealdb": "workspace:^",
"@suaveplan/types": "workspace:^",
"hono": "catalog:",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "1.30.0",
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@suaveplan/testing-integration-surrealdb": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "surql", "README.md"]
}

View File

@@ -0,0 +1,198 @@
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import "./services/registry-augmentation.js";
import {
AttendeeCountSchema,
AttendeeListResponseSchema,
AttendeeQuerySchema,
AttendeeSchema,
type BuildVCalendarInput,
buildVCalendar,
CreateEventSchema,
CreateRsvpSchema,
createEventRoutes,
createEventsPlugin,
createIcsExportRoutes,
EVENTS_PLUGIN_CODES,
EventIdParamSchema,
EventListResponseSchema,
EventLocationSchema,
EventQuerySchema,
EventRepository,
EventSchema,
EventStatusSchema,
type EventsInstruments,
type EventsPluginConfigInput,
EventsPluginError,
type EventsPluginErrorCode,
EventsPluginOptionsSchema,
type EventsRouteEvent,
type EventsRoutesVariables,
getEventsInstruments,
type IcsAttendee,
type IcsExportRoutesConfig,
type IEventRepository,
InviteResponseSchema,
InviteUsersSchema,
type IRsvpRepository,
RsvpRepository,
RsvpResponseSchema,
RsvpSchema,
RsvpStatusSchema,
resetEventsInstruments,
UpdateEventSchema,
} from "./index.js";
describe("public surface", () => {
it("re-exports every documented symbol", { timeout: 5000 }, () => {
const surface = {
createEventsPlugin,
createEventRoutes,
EventsPluginError,
EVENTS_PLUGIN_CODES,
getEventsInstruments,
resetEventsInstruments,
EventRepository,
RsvpRepository,
EventsPluginOptionsSchema,
EventSchema,
CreateEventSchema,
UpdateEventSchema,
EventQuerySchema,
EventIdParamSchema,
EventLocationSchema,
EventStatusSchema,
EventListResponseSchema,
AttendeeCountSchema,
RsvpSchema,
RsvpStatusSchema,
CreateRsvpSchema,
RsvpResponseSchema,
AttendeeSchema,
AttendeeQuerySchema,
AttendeeListResponseSchema,
InviteUsersSchema,
InviteResponseSchema,
buildVCalendar,
createIcsExportRoutes,
};
for (const [name, value] of Object.entries(surface)) {
if (value === undefined) {
throw new Error(
`symbol ${name} is undefined in the package root`
);
}
}
});
it("EventsPluginErrorCode narrows to the EVENTS_PLUGIN_CODES value type", {
timeout: 5000,
}, () => {
expectTypeOf<EventsPluginErrorCode>().toEqualTypeOf<
(typeof EVENTS_PLUGIN_CODES)[keyof typeof EVENTS_PLUGIN_CODES]
>();
});
it("EventsInstruments exposes seven counters", { timeout: 5000 }, () => {
expectTypeOf<EventsInstruments>().toHaveProperty("eventsCreatedTotal");
expectTypeOf<EventsInstruments>().toHaveProperty("eventsUpdatedTotal");
expectTypeOf<EventsInstruments>().toHaveProperty("eventsDeletedTotal");
expectTypeOf<EventsInstruments>().toHaveProperty("rsvpsWrittenTotal");
expectTypeOf<EventsInstruments>().toHaveProperty(
"invitationsSentTotal"
);
expectTypeOf<EventsInstruments>().toHaveProperty("icsServedTotal");
expectTypeOf<EventsInstruments>().toHaveProperty("icsFailedTotal");
});
it("re-exports ICS surface (builder + routes)", { timeout: 5000 }, () => {
expect(buildVCalendar).toBeDefined();
expect(createIcsExportRoutes).toBeDefined();
expectTypeOf<BuildVCalendarInput>().toHaveProperty("event");
expectTypeOf<BuildVCalendarInput>().toHaveProperty("nowMs");
expectTypeOf<IcsExportRoutesConfig>().toHaveProperty("eventRepository");
expectTypeOf<IcsAttendee>().toHaveProperty("status");
});
it("EventsPluginConfigInput allows partial config", {
timeout: 5000,
}, () => {
const input: EventsPluginConfigInput = {};
expect(input).toBeDefined();
});
it("EventsRouteEvent is a discriminated union", { timeout: 5000 }, () => {
const event: EventsRouteEvent = {
type: "event:created",
payload: { eventId: "event:1", creatorId: "user:1" },
};
expect(event.type).toBe("event:created");
});
it("EventsRoutesVariables carries the user slot", { timeout: 5000 }, () => {
expectTypeOf<EventsRoutesVariables>().toHaveProperty("user");
});
});
describe("service registry augmentation", () => {
it("ServiceRegistry exposes eventRepository and rsvpRepository slots", {
timeout: 5000,
}, () => {
// Compile-time only — if the registry augmentation were
// missing, the literal strings would fail to satisfy
// `keyof ServiceRegistry` in the lookup.
const lookups: Array<"eventRepository" | "rsvpRepository"> = [
"eventRepository",
"rsvpRepository",
];
expect(lookups).toHaveLength(2);
// Runtime sanity
const fake: IEventRepository = {
findAll: async () => [],
findById: async () => null,
create: async () => ({
id: "event:1",
title: "T",
start_time: "",
end_time: "",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "",
updated_at: "",
}),
update: async () => ({
id: "event:1",
title: "T",
start_time: "",
end_time: "",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "",
updated_at: "",
}),
delete: async () => undefined,
isCreator: async () => true,
updateAttendeeCount: async () => undefined,
};
const fakeRsvp: IRsvpRepository = {
findByEventAndUser: async () => null,
findAttendees: async () => [],
upsert: async () => ({
id: "event_rsvp:1",
event: "event:1",
user: "user:1",
status: "going",
created_at: "",
updated_at: "",
}),
delete: async () => null,
createInvitations: async () => [],
countByStatus: async () => 0,
};
expect(fake).toBeDefined();
expect(fakeRsvp).toBeDefined();
});
});

View File

@@ -0,0 +1,123 @@
# error-codes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error-code registry and concrete error class for
`@suaveplan/api-plugin-events`. Every failure path in the plugin —
configuration validation, lifecycle wiring, repository write paths,
route-handler authorisation — raises an `EventsPluginError` carrying
exactly one of the twelve `EVENTS_PLUGIN.*` codes. The registry is the
contract: consumers branch on `error.code === EVENTS_PLUGIN_CODES.RSVP_NOT_FOUND`
and receive `EventsPluginErrorCode` narrowing for free.
The namespace literal is `"EVENTS_PLUGIN"` rather than the obvious
`"EVENTS"` to avoid a hard collision with `@suaveplan/events` (the
typed event-bus already shipped ). The event-bus library
owns the `EVENTS_*` prefix; this plugin uses the longer prefix so
both registries can coexist in the same project without either
having to rename codes downstream.
## Features
- 12 documented codes covering every public failure surface.
- `EventsPluginError` extends `SuaveplanError` so `instanceof` narrows
`error.code` to `EventsPluginErrorCode`.
- Side-effect-free import — `import "./error-codes.js"` for code
registry registration with zero runtime cost.
- `cause` chain + `context` bag forwarded to the
`@suaveplan/error` base class for OTel span events and i18n
resolution.
## Usage: Basic Example
```ts
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
} from "@suaveplan/api-plugin-events";
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.EVENT_NOT_FOUND,
"Event not found"
);
```
## Usage: Advanced Example
```ts
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
type EventsPluginErrorCode,
} from "@suaveplan/api-plugin-events";
function describe(code: EventsPluginErrorCode): string {
switch (code) {
case EVENTS_PLUGIN_CODES.INVALID_CONFIG:
return "configuration rejected by Zod";
case EVENTS_PLUGIN_CODES.EVENT_CREATE_FAILED:
return "adapter returned no row from CREATE event";
default:
return code;
}
}
try {
// ...
} catch (err) {
if (err instanceof EventsPluginError) {
const label = describe(err.code);
// forward to logger / OTel
console.error(label, err.context, err.cause);
}
}
```
## API Reference
### `EVENTS_PLUGIN_CODES`
A frozen object mapping each code key to its stable string value:
- `INVALID_CONFIG` — Zod-validated options were rejected.
- `SERVICE_NOT_AVAILABLE` — required `ServiceRegistry` slot missing.
- `ROUTE_PREFIX_COLLISION` — `onReady` would shadow existing routes.
- `EVENT_CREATE_FAILED` / `EVENT_UPDATE_FAILED` — adapter returned no row.
- `RSVP_CREATE_FAILED` / `RSVP_UPDATE_FAILED` — adapter returned no row.
- `RSVP_NOT_FOUND` / `EVENT_NOT_FOUND` — lookup miss.
- `UNAUTHENTICATED` — `c.get("user")` was missing on a route.
- `FORBIDDEN` — non-creator hit a mutating endpoint.
- `INVITE_LIMIT_EXCEEDED` — `user_ids` length exceeded the runtime cap.
### `EventsPluginErrorCode`
Union of all values in `EVENTS_PLUGIN_CODES` — useful for exhaustive
`switch` over caught errors.
### `EventsPluginError`
```ts
class EventsPluginError extends SuaveplanError<EventsPluginErrorCode> {
constructor(
code: EventsPluginErrorCode,
message: string,
options?: SuaveplanErrorOptions
);
readonly eventsPluginCode: EventsPluginErrorCode;
}
```
## Implementation Notes
The 12-code registry is fixed for `0.x` — adding a code is a minor
bump, removing or renaming one is a major bump. The
`error-codes.test.ts` reflects over the registry and grep-asserts
each value reaches a `throw new EventsPluginError` call site under
`src/`; that test is the canonical proof that no code is a dangling
declaration. Removing a code to make the test pass is explicitly
banned by `AGENTS.md` §2c — wire the missing throw site
instead.

View File

@@ -0,0 +1,79 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EVENTS_PLUGIN_CODES, EventsPluginError } from "./error-codes.js";
const SRC_ROOT = join(__dirname, "..");
async function* walk(dir: string): AsyncGenerator<string> {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) yield* walk(path);
else if (
entry.isFile() &&
path.endsWith(".ts") &&
!path.endsWith(".test.ts")
) {
yield path;
}
}
}
async function readAllSources(): Promise<string> {
let combined = "";
for await (const path of walk(SRC_ROOT)) {
combined += await readFile(path, "utf8");
}
return combined;
}
describe("EVENTS_PLUGIN_CODES", () => {
it("namespace literal is EVENTS_PLUGIN (avoids collision with @suaveplan/events)", {
timeout: 5000,
}, () => {
for (const value of Object.values(EVENTS_PLUGIN_CODES)) {
expect(value.startsWith("EVENTS_PLUGIN.")).toBe(true);
}
});
it("constructs EventsPluginError with the declared code", {
timeout: 5000,
}, () => {
const err = new EventsPluginError(
EVENTS_PLUGIN_CODES.INVALID_CONFIG,
"boom"
);
expect(err.code).toBe(EVENTS_PLUGIN_CODES.INVALID_CONFIG);
expect(err.eventsPluginCode).toBe(EVENTS_PLUGIN_CODES.INVALID_CONFIG);
expect(err.message).toBe("boom");
});
it("every EVENTS_PLUGIN_CODES value is thrown by src/", {
timeout: 15000,
}, async () => {
const combined = await readAllSources();
for (const [key, value] of Object.entries(EVENTS_PLUGIN_CODES)) {
// The throw site spells the code via the constant —
// `EVENTS_PLUGIN_CODES.SOMETHING` — not the literal
// string. Match on the constant identifier.
const probe = `EVENTS_PLUGIN_CODES.${key}`;
if (!combined.includes(probe)) {
throw new Error(
`Code ${value} (${probe}) is never referenced by a production source file`
);
}
}
});
it("preserves cause and context options", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new EventsPluginError(
EVENTS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"outer",
{ cause, context: { foo: "bar" } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ foo: "bar" });
});
});

View File

@@ -0,0 +1,82 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-plugin-events`.
*
* The namespace literal is `"EVENTS_PLUGIN"` (NOT `"EVENTS"`) so the
* registry does not collide with `@suaveplan/events`, the typed
* event-bus library that already owns the `EVENTS_*` prefix.
*
* Every code is thrown from at least one production path under
* `src/`; the spec-conformance test
* (`src/error-codes/error-codes.test.ts`) reflects over the registry
* and grep-asserts each code reaches a `throw new EventsPluginError`
* site.
*
* - `INVALID_CONFIG` — `createEventsPlugin(options)` received options
* that failed `EventsPluginOptionsSchema`. `cause` is the
* `ZodError`.
* - `SERVICE_NOT_AVAILABLE` — a lifecycle hook required a service
* slot (`db`, `app`) that was not registered on the host.
* - `ROUTE_PREFIX_COLLISION` — `onReady` detected that the configured
* `basePath` already has routes mounted; mounting twice would
* shadow the first registration.
* - `EVENT_CREATE_FAILED` — `EventRepository.create` saw the
* underlying adapter return an empty / null result row.
* - `EVENT_UPDATE_FAILED` — `EventRepository.update` saw the adapter
* return an empty / null row.
* - `RSVP_CREATE_FAILED` — `RsvpRepository.upsert` saw an empty
* create response from the adapter.
* - `RSVP_UPDATE_FAILED` — `RsvpRepository.upsert` saw an empty
* update response from the adapter.
* - `RSVP_NOT_FOUND` — `DELETE /:id/rsvp` was called for a user with
* no RSVP row on the event.
* - `EVENT_NOT_FOUND` — a route handler looked up an event by id and
* the repository returned `null`.
* - `UNAUTHENTICATED` — a route handler ran without `c.get("user")`
* set by the host's auth middleware.
* - `FORBIDDEN` — a mutating route was hit by a user who is not the
* event creator.
* - `INVITE_LIMIT_EXCEEDED` — the `user_ids` array on the invite
* endpoint exceeded `maxInvitesPerRequest`.
* - `ICS_INVALID_QUERY` — the `GET /:id/ics` route received a `with`
* query parameter whose value is not in the allowed enum (`rsvps`).
* Surfaced to the client as HTTP `422`.
*/
export const EVENTS_PLUGIN_CODES = defineCodes("EVENTS_PLUGIN", [
"INVALID_CONFIG",
"SERVICE_NOT_AVAILABLE",
"ROUTE_PREFIX_COLLISION",
"EVENT_CREATE_FAILED",
"EVENT_UPDATE_FAILED",
"RSVP_CREATE_FAILED",
"RSVP_UPDATE_FAILED",
"RSVP_NOT_FOUND",
"EVENT_NOT_FOUND",
"UNAUTHENTICATED",
"FORBIDDEN",
"INVITE_LIMIT_EXCEEDED",
"ICS_INVALID_QUERY",
] as const);
export type EventsPluginErrorCode =
(typeof EVENTS_PLUGIN_CODES)[keyof typeof EVENTS_PLUGIN_CODES];
/**
* Concrete error class. Extends {@link SuaveplanError} so
* `instanceof EventsPluginError` narrows `error.code` to
* {@link EventsPluginErrorCode}.
*/
export class EventsPluginError extends SuaveplanError<EventsPluginErrorCode> {
public readonly eventsPluginCode: EventsPluginErrorCode;
public constructor(
code: EventsPluginErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.eventsPluginCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export type { EventsPluginErrorCode } from "./error-codes.js";
export {
EVENTS_PLUGIN_CODES,
EventsPluginError,
} from "./error-codes.js";

View File

@@ -0,0 +1,149 @@
# ics-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ics-builder` module emits a complete RFC 5545 `VCALENDAR` document
for a single event document loaded from `EventRepository`. It is a
dependency-free, pure-function module so the same builder can be reused
by future surfaces (cron-driven email digests, signed feed URLs,
calendar-app push delivery) without dragging in route plumbing or a
third-party `ical` library.
The builder is the load-bearing piece of the `GET /:id/ics` endpoint
shipped in `add-events-ics-export`: every escape rule, line-fold
decision, and CRLF terminator the spec mandates lives in this module
and is unit-tested at the byte level. Routes only assemble inputs and
forward the resulting string.
## Features
- `buildVCalendar({ event, attendees?, nowMs })` — top-level builder.
Emits one `BEGIN:VCALENDAR` … `END:VCALENDAR` block containing one
`VEVENT`. When `attendees` is provided, one `ATTENDEE` line is
appended per row.
- `escapeText(raw)` — RFC 5545 §3.3.11 text escape (`\\`, `\;`, `\,`,
CR/LF → `\n`).
- `foldLine(line)` — RFC 5545 §3.1 line folding at 75-octet boundaries.
UTF-8 safe (pulls cut back to a start byte when needed).
- `formatDateUtc(ms)` — UTC `YYYYMMDDTHHMMSSZ` form.
- `mapStatus(intStatus)` — event status integer → VEVENT `STATUS`
string (`0`→`TENTATIVE`, `1`→`CONFIRMED`, `2`→`CANCELLED`).
- `mapAttendeeStatus(rsvpStatus)` → `PARTSTAT` value (`going`→
`ACCEPTED`, `maybe`→`TENTATIVE`, `not_going`→`DECLINED`,
`invited`→`NEEDS-ACTION`).
- CRLF (`\r\n`) line endings throughout; every output line including
the final `END:VCALENDAR` terminates with CRLF.
## Usage: Basic
```ts
import { buildVCalendar } from "@suaveplan/api-plugin-events/ics/ics-builder/ics-builder";
const ics = buildVCalendar({
event: {
id: "event:1",
title: "Quarterly review",
description: "Planning + retro.",
start_time: "2026-06-01T10:00:00Z",
end_time: "2026-06-01T11:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
},
nowMs: Date.parse("2026-05-20T12:00:00Z"),
});
// → "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:...\r\nBEGIN:VEVENT\r\n..."
```
## Usage: Advanced
Emit the `?with=rsvps` variant by passing an attendee roster. The
builder maps each RSVP status to its `PARTSTAT` parameter and escapes
the display name per §3.3.11.
```ts
import { buildVCalendar } from "@suaveplan/api-plugin-events/ics/ics-builder/ics-builder";
const ics = buildVCalendar({
event: {
id: "event:1",
title: "Annual gala; tickets, mandatory",
start_time: "2026-12-31T18:00:00Z",
end_time: "2027-01-01T00:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 1, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
},
attendees: [
{
id: "event_rsvp:1",
user: "user:42",
user_name: "Ada, of Lovelace",
user_email: "ada@example.com",
status: "going",
},
{
id: "event_rsvp:2",
user: "user:43",
status: "maybe",
},
],
nowMs: Date.parse("2026-05-20T12:00:00Z"),
});
// The first attendee renders as:
// ATTENDEE;PARTSTAT=ACCEPTED;CN=Ada\, of Lovelace:mailto:ada@example.com
// The second falls back to the non-routable @suaveplan.local domain:
// ATTENDEE;PARTSTAT=TENTATIVE:mailto:user:43@suaveplan.local
```
## API Reference
- `buildVCalendar(input: BuildVCalendarInput): string` — returns the
full VCALENDAR document, terminated by CRLF. Throws nothing —
malformed input (e.g. unparseable `start_time`) produces a
best-effort `Invalid Date`-shaped UTC stamp which is the
responsibility of the calling route to detect and avoid.
- `escapeText(raw: string): string` — escapes backslash, semicolon,
comma, and any CR/LF to `\n` (two literal characters: `\` + `n`).
- `foldLine(line: string): string` — folds long lines per §3.1.
Returns the input unchanged when ≤75 octets.
- `formatDateUtc(ms: number): string` — UTC date-time per §3.3.5.
- `mapStatus(status: number): IcsEventStatus` — total function.
Defaults to `CONFIRMED` for any status other than `0` or `2`.
- `mapAttendeeStatus(status: string): IcsAttendeePartStat` — total
function. Returns `NEEDS-ACTION` for unknown / `invited` values.
## Implementation Notes
- **No `ical` dependency.** The 75-octet fold + four escape rules are
small enough to maintain locally; pulling a third-party module would
add a transitive surface that has historically caused calendar-app
interop bugs (escaping policy varies). The spec is RFC-pinned, not
library-pinned.
- **UTC only.** Events on the plugin are stored UTC per the existing
service contract. VTIMEZONE blocks are a follow-up change and not
needed until the plugin learns to store / surface a non-UTC tzid.
- **`SEQUENCE:0`.** The event schema has no `revision` / `version`
field. Until one ships, every export emits `SEQUENCE:0`. The
CALDAV / iCloud conformance rule of "bump SEQUENCE on every
mutation" therefore has nothing to do here; the constant is
forward-safe and is preferred to omission.
- **UTF-8 fold safety.** When the 75-octet boundary lands inside a
multi-byte sequence, the cut is pulled back to the previous start
byte. The maximum reduction is 3 octets (the longest BMP
continuation). Pure-ASCII input — the common case — folds at
exactly 75 octets.
- **`ORGANIZER` not emitted.** The event schema has no host email
field. The omission is documented in the proposal so a future
schema migration can plug the value in without re-litigating the
decision.

View File

@@ -0,0 +1,356 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { Event } from "../../schemas/event-schemas.js";
import {
buildVCalendar,
escapeText,
foldLine,
formatDateUtc,
mapAttendeeStatus,
mapStatus,
} from "./ics-builder.js";
import type { IcsAttendee } from "./ics-builder.types.js";
const baseEvent: Event = {
id: "event:1",
title: "T",
description: "d",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
timezone: "UTC",
creator: "user:1",
location: { name: "L" },
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const NOW_MS = Date.parse("2026-05-20T12:34:56Z");
describe("escapeText", () => {
it("escapeText escapes comma semicolon backslash and newline", {
timeout: 5000,
}, () => {
expect(escapeText("a,b;c\\d\ne")).toBe("a\\,b\\;c\\\\d\\ne");
});
it("escapeText normalises CRLF to a single \\n", { timeout: 5000 }, () => {
expect(escapeText("a\r\nb")).toBe("a\\nb");
});
it("escapeText leaves plain text untouched", { timeout: 5000 }, () => {
expect(escapeText("Hello world 42")).toBe("Hello world 42");
});
it("escapeText normalises a bare CR to \\n", { timeout: 5000 }, () => {
expect(escapeText("a\rb")).toBe("a\\nb");
});
});
describe("formatDateUtc", () => {
it("formats UTC milliseconds as YYYYMMDDTHHMMSSZ", {
timeout: 5000,
}, () => {
const ms = Date.parse("2026-01-02T03:04:05Z");
expect(formatDateUtc(ms)).toBe("20260102T030405Z");
});
it("zero-pads month / day / time components", { timeout: 5000 }, () => {
const ms = Date.parse("2026-01-09T00:00:00Z");
expect(formatDateUtc(ms)).toBe("20260109T000000Z");
});
});
describe("mapStatus", () => {
it("0 → TENTATIVE", { timeout: 5000 }, () => {
expect(mapStatus(0)).toBe("TENTATIVE");
});
it("1 → CONFIRMED", { timeout: 5000 }, () => {
expect(mapStatus(1)).toBe("CONFIRMED");
});
it("2 → CANCELLED", { timeout: 5000 }, () => {
expect(mapStatus(2)).toBe("CANCELLED");
});
it("unknown values default to CONFIRMED", { timeout: 5000 }, () => {
expect(mapStatus(7)).toBe("CONFIRMED");
});
});
describe("mapAttendeeStatus", () => {
it("maps RSVP status to ATTENDEE PARTSTAT", { timeout: 5000 }, () => {
expect(mapAttendeeStatus("going")).toBe("ACCEPTED");
expect(mapAttendeeStatus("maybe")).toBe("TENTATIVE");
expect(mapAttendeeStatus("not_going")).toBe("DECLINED");
expect(mapAttendeeStatus("invited")).toBe("NEEDS-ACTION");
});
it("unknown status → NEEDS-ACTION", { timeout: 5000 }, () => {
expect(mapAttendeeStatus("anything-else")).toBe("NEEDS-ACTION");
});
});
describe("foldLine", () => {
it("leaves a ≤75-octet line untouched", { timeout: 5000 }, () => {
const line = "x".repeat(75);
expect(foldLine(line)).toBe(line);
});
it("splits at the 75-octet boundary with leading SPACE continuation", {
timeout: 5000,
}, () => {
const line = "x".repeat(76);
const folded = foldLine(line);
expect(folded).toBe(`${"x".repeat(75)}\r\n x`);
});
it("produces three segments for a 223-octet ASCII line", {
timeout: 5000,
}, () => {
// First segment = 75 octets, then continuations carry
// 74 payload octets each (the leading SPACE counts).
// 75 + 74 + 74 = 223 octets → exactly three segments.
const line = "y".repeat(75 + 74 + 74);
const folded = foldLine(line);
const parts = folded.split("\r\n");
expect(parts).toHaveLength(3);
expect(parts[0]).toBe("y".repeat(75));
expect(parts[1]).toBe(` ${"y".repeat(74)}`);
expect(parts[2]).toBe(` ${"y".repeat(74)}`);
});
it("pulls cut back to a UTF-8 start byte rather than splitting a codepoint", {
timeout: 5000,
}, () => {
// The em-dash (U+2014) is 3 octets in UTF-8 (E2 80 94).
// Build a 73-octet ASCII run + em-dash so a naive 75-octet
// cut would land in the middle of the em-dash.
const ascii = "z".repeat(73);
const line = `${ascii}—abc`;
const folded = foldLine(line);
// The em-dash must move WHOLE to the continuation segment.
const parts = folded.split("\r\n");
expect(parts.length).toBeGreaterThanOrEqual(2);
expect(parts[0]).toBe(ascii);
expect(parts[1]).toBe(" —abc");
});
});
describe("buildVCalendar", () => {
it("emits every required VEVENT property in canonical order", {
timeout: 5000,
}, () => {
const out = buildVCalendar({ event: baseEvent, nowMs: NOW_MS });
const lines = out.split("\r\n");
// Expect a trailing empty string after the final CRLF.
expect(lines[lines.length - 1]).toBe("");
const compact = lines.filter((l) => l.length > 0);
expect(compact[0]).toBe("BEGIN:VCALENDAR");
expect(compact[1]).toBe("VERSION:2.0");
expect(compact[2]?.startsWith("PRODID:-//Suaveplan//")).toBe(true);
expect(compact[3]).toBe("BEGIN:VEVENT");
expect(compact[4]).toBe("UID:event:1@suaveplan.events");
expect(compact[5]).toBe(`DTSTAMP:${formatDateUtc(NOW_MS)}`);
expect(compact[6]).toBe("DTSTART:20260101T100000Z");
expect(compact[7]).toBe("DTEND:20260101T120000Z");
expect(compact[8]).toBe("SUMMARY:T");
expect(compact[9]).toBe("DESCRIPTION:d");
expect(compact[10]).toBe("LOCATION:L");
expect(compact[11]).toBe("STATUS:CONFIRMED");
expect(compact[12]).toBe("SEQUENCE:0");
expect(compact[13]).toBe("END:VEVENT");
expect(compact[14]).toBe("END:VCALENDAR");
});
it("maps status integer to RFC 5545 STATUS value", {
timeout: 5000,
}, () => {
const draft = buildVCalendar({
event: { ...baseEvent, status: 0 },
nowMs: NOW_MS,
});
const cancelled = buildVCalendar({
event: { ...baseEvent, status: 2 },
nowMs: NOW_MS,
});
expect(draft).toContain("STATUS:TENTATIVE");
expect(cancelled).toContain("STATUS:CANCELLED");
});
it("omits DESCRIPTION and LOCATION when absent", { timeout: 5000 }, () => {
const out = buildVCalendar({
event: {
...baseEvent,
description: undefined,
location: undefined,
},
nowMs: NOW_MS,
});
expect(out).not.toContain("DESCRIPTION:");
expect(out).not.toContain("LOCATION:");
});
it("omits DESCRIPTION when empty string and LOCATION when empty name", {
timeout: 5000,
}, () => {
const out = buildVCalendar({
event: {
...baseEvent,
description: "",
location: { name: "" },
},
nowMs: NOW_MS,
});
expect(out).not.toContain("DESCRIPTION:");
expect(out).not.toContain("LOCATION:");
});
it("every output line ends in CRLF", { timeout: 5000 }, () => {
const out = buildVCalendar({ event: baseEvent, nowMs: NOW_MS });
// No bare LF anywhere.
expect(/[^\r]\n/.test(out)).toBe(false);
expect(out.endsWith("\r\n")).toBe(true);
});
it("folds a 200-octet DESCRIPTION into 75-octet continuation segments", {
timeout: 5000,
}, () => {
const long = "a".repeat(200);
const out = buildVCalendar({
event: { ...baseEvent, description: long },
nowMs: NOW_MS,
});
// Pull the folded DESCRIPTION block out.
const start = out.indexOf("DESCRIPTION:");
const rest = out.slice(start);
// First physical line: "DESCRIPTION:" (12) + 63 payload octets = 75.
const firstNewline = rest.indexOf("\r\n");
expect(firstNewline).toBe(75);
// Continuation lines all start with a single SPACE.
const afterFirst = rest.slice(firstNewline + 2);
expect(afterFirst.startsWith(" ")).toBe(true);
});
it("escapes SUMMARY content per §3.3.11", { timeout: 5000 }, () => {
const out = buildVCalendar({
event: { ...baseEvent, title: "a, b; c\\ d" },
nowMs: NOW_MS,
});
expect(out).toContain("SUMMARY:a\\, b\\; c\\\\ d");
});
it("emits one ATTENDEE per RSVP when attendees provided", {
timeout: 5000,
}, () => {
const attendees: IcsAttendee[] = [
{
id: "event_rsvp:1",
user: "user:1",
user_name: "Alice",
user_email: "alice@example.com",
status: "going",
},
{
id: "event_rsvp:2",
user: "user:2",
status: "maybe",
},
];
const out = buildVCalendar({
event: baseEvent,
attendees,
nowMs: NOW_MS,
});
const matches = out.match(/ATTENDEE;[^\r\n]*/g);
expect(matches).not.toBeNull();
expect(matches?.length).toBe(2);
expect(out).toContain(
"ATTENDEE;PARTSTAT=ACCEPTED;CN=Alice:mailto:alice@example.com"
);
expect(out).toContain(
"ATTENDEE;PARTSTAT=TENTATIVE:mailto:user:2@suaveplan.local"
);
});
it("escapes ATTENDEE CN parameter value", { timeout: 5000 }, () => {
const attendees: IcsAttendee[] = [
{
id: "event_rsvp:1",
user: "user:1",
user_name: "Ada, of Lovelace",
user_email: "ada@example.com",
status: "going",
},
];
const out = buildVCalendar({
event: baseEvent,
attendees,
nowMs: NOW_MS,
});
expect(out).toContain(
"ATTENDEE;PARTSTAT=ACCEPTED;CN=Ada\\, of Lovelace:mailto:ada@example.com"
);
});
it("drops DQUOTE characters from ATTENDEE CN parameter", {
timeout: 5000,
}, () => {
const attendees: IcsAttendee[] = [
{
id: "event_rsvp:1",
user: "user:1",
user_name: 'Ada "the Countess"',
user_email: "ada@example.com",
status: "going",
},
];
const out = buildVCalendar({
event: baseEvent,
attendees,
nowMs: NOW_MS,
});
// DQUOTE is dropped (escapeParam strips it).
expect(out).toContain(
"ATTENDEE;PARTSTAT=ACCEPTED;CN=Ada the Countess:mailto:ada@example.com"
);
});
it("renders ATTENDEE without CN when user_name is empty", {
timeout: 5000,
}, () => {
const attendees: IcsAttendee[] = [
{
id: "event_rsvp:1",
user: "user:1",
user_name: "",
user_email: "x@example.com",
status: "not_going",
},
];
const out = buildVCalendar({
event: baseEvent,
attendees,
nowMs: NOW_MS,
});
expect(out).toContain(
"ATTENDEE;PARTSTAT=DECLINED:mailto:x@example.com"
);
});
it("renders attendee with invited status as NEEDS-ACTION", {
timeout: 5000,
}, () => {
const attendees: IcsAttendee[] = [
{
id: "event_rsvp:1",
user: "user:1",
status: "invited",
},
];
const out = buildVCalendar({
event: baseEvent,
attendees,
nowMs: NOW_MS,
});
expect(out).toContain("PARTSTAT=NEEDS-ACTION");
});
});

View File

@@ -0,0 +1,204 @@
import type { Event } from "../../schemas/event-schemas.js";
import type {
BuildVCalendarInput,
IcsAttendee,
IcsAttendeePartStat,
IcsEventStatus,
} from "./ics-builder.types.js";
/**
* Hand-rolled RFC 5545 iCalendar builder.
*
* The builder is dependency-free by design — pulling in a third-party
* `ical` package for ~200 LoC of fold + escape logic would dwarf the
* implementation. Every output line ends in CRLF (`\r\n`) per §3.1 and
* every text-value field is escaped per §3.3.11 before being folded at
* the 75-octet boundary.
*/
const CRLF = "\r\n";
const PRODID = "-//Suaveplan//api-plugin-events 0.1//EN";
/**
* Escape free-form text per RFC 5545 §3.3.11. The four sensitive
* characters are backslash (must come first), semicolon, comma, and
* newline. Carriage returns are normalised away so a CRLF in the input
* becomes a single `\n` escape sequence in the output.
*
* @param raw arbitrary user-supplied string
* @returns escaped string safe to embed in `SUMMARY` / `DESCRIPTION` /
* `LOCATION` (or as a parameter value via `escapeParam`).
*/
export function escapeText(raw: string): string {
return raw
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\r\n/g, "\n")
.replace(/\r/g, "\n")
.replace(/\n/g, "\\n");
}
/**
* Escape a parameter value (e.g. `CN=...`). Per §3.2 quoted parameter
* values may not contain DQUOTE; the safe-conservative approach used
* here is to backslash-escape on the same rule set as text.
*/
function escapeParam(raw: string): string {
return escapeText(raw).replace(/"/g, "");
}
/**
* Format a millisecond UTC timestamp as RFC 5545 §3.3.5 date-time
* (UTC form): `YYYYMMDDTHHMMSSZ`. The `Z` suffix is mandatory — every
* value the plugin emits is UTC, matching the event-schema contract
* (events are stored UTC; VTIMEZONE blocks are out of scope for v0.1).
*/
export function formatDateUtc(ms: number): string {
const d = new Date(ms);
const yyyy = d.getUTCFullYear().toString().padStart(4, "0");
const mm = (d.getUTCMonth() + 1).toString().padStart(2, "0");
const dd = d.getUTCDate().toString().padStart(2, "0");
const hh = d.getUTCHours().toString().padStart(2, "0");
const mi = d.getUTCMinutes().toString().padStart(2, "0");
const ss = d.getUTCSeconds().toString().padStart(2, "0");
return `${yyyy}${mm}${dd}T${hh}${mi}${ss}Z`;
}
/** Map the plugin's event-status integer to a VEVENT `STATUS` value. */
export function mapStatus(status: number): IcsEventStatus {
if (status === 0) return "TENTATIVE";
if (status === 2) return "CANCELLED";
return "CONFIRMED";
}
/** Map an RSVP status to an `ATTENDEE` `PARTSTAT` parameter value. */
export function mapAttendeeStatus(status: string): IcsAttendeePartStat {
if (status === "going") return "ACCEPTED";
if (status === "maybe") return "TENTATIVE";
if (status === "not_going") return "DECLINED";
return "NEEDS-ACTION";
}
/**
* Fold a single content line to RFC 5545 §3.1's 75-octet boundary.
*
* The first physical line carries up to 75 octets; every subsequent
* physical line begins with one SPACE (the §3.1 linear-white-space
* unfolding marker) followed by up to 74 octets of payload, capping
* the physical line at 75 octets total. Lines are joined with CRLF.
*
* The fold operates on the UTF-8 byte stream so multi-byte codepoints
* are not split mid-sequence: when the boundary lands inside a
* continuation byte, the cut is pulled back to the previous start
* byte. Pure-ASCII inputs (the common case) fold at exactly 75 octets.
*/
export function foldLine(line: string): string {
const bytes = new TextEncoder().encode(line);
if (bytes.length <= 75) return line;
const decoder = new TextDecoder("utf-8", { fatal: false });
const segments: string[] = [];
let cursor = 0;
let limit = 75;
while (cursor < bytes.length) {
let end = Math.min(cursor + limit, bytes.length);
// Pull back to a UTF-8 start byte (0xxxxxxx or 11xxxxxx).
// `bytes[end]` is provably defined here because `end < bytes.length`,
// so we narrow via a local `byte` const that is non-undefined.
while (end < bytes.length && end > cursor + 1) {
const byte = bytes[end] as number;
if (byte < 0x80 || byte >= 0xc0) break;
end--;
}
segments.push(decoder.decode(bytes.subarray(cursor, end)));
cursor = end;
// Subsequent physical lines carry one leading SPACE → 74 payload.
limit = 74;
}
return segments.map((seg, i) => (i === 0 ? seg : ` ${seg}`)).join(CRLF);
}
function emit(lines: string[], raw: string): void {
lines.push(foldLine(raw));
}
function buildEventLines(event: Event, nowMs: number): string[] {
const lines: string[] = [];
const eventId = String(event.id);
emit(lines, "BEGIN:VEVENT");
emit(lines, `UID:${eventId}@suaveplan.events`);
emit(lines, `DTSTAMP:${formatDateUtc(nowMs)}`);
emit(lines, `DTSTART:${formatDateUtc(Date.parse(event.start_time))}`);
emit(lines, `DTEND:${formatDateUtc(Date.parse(event.end_time))}`);
emit(lines, `SUMMARY:${escapeText(event.title)}`);
if (event.description !== undefined && event.description !== "") {
emit(lines, `DESCRIPTION:${escapeText(event.description)}`);
}
if (event.location?.name !== undefined && event.location.name !== "") {
emit(lines, `LOCATION:${escapeText(event.location.name)}`);
}
emit(lines, `STATUS:${mapStatus(event.status)}`);
emit(lines, "SEQUENCE:0");
return lines;
}
function buildAttendeeLine(attendee: IcsAttendee): string {
const partstat = mapAttendeeStatus(attendee.status);
const params: string[] = [`PARTSTAT=${partstat}`];
if (attendee.user_name !== undefined && attendee.user_name !== "") {
params.push(`CN=${escapeParam(attendee.user_name)}`);
}
const address =
attendee.user_email !== undefined && attendee.user_email !== ""
? attendee.user_email
: `${attendee.user}@suaveplan.local`;
return `ATTENDEE;${params.join(";")}:mailto:${address}`;
}
/**
* Build a complete VCALENDAR document for the given event.
*
* Output layout (every line terminated by CRLF, every long line folded
* at 75 octets, every text value escaped per §3.3.11):
*
* BEGIN:VCALENDAR
* VERSION:2.0
* PRODID:-//Suaveplan//api-plugin-events 0.1//EN
* BEGIN:VEVENT
* UID:<id>@suaveplan.events
* DTSTAMP:<utc>
* DTSTART:<utc>
* DTEND:<utc>
* SUMMARY:<escaped title>
* DESCRIPTION:<escaped description> (optional)
* LOCATION:<escaped location> (optional)
* STATUS:CONFIRMED | TENTATIVE | CANCELLED
* SEQUENCE:0
* ATTENDEE;...:mailto:... (repeats — only when attendees supplied)
* END:VEVENT
* END:VCALENDAR
*/
export function buildVCalendar(input: BuildVCalendarInput): string {
const lines: string[] = [];
emit(lines, "BEGIN:VCALENDAR");
emit(lines, "VERSION:2.0");
emit(lines, `PRODID:${PRODID}`);
for (const evLine of buildEventLines(input.event, input.nowMs)) {
lines.push(evLine);
}
if (input.attendees !== undefined) {
for (const a of input.attendees) {
emit(lines, buildAttendeeLine(a));
}
}
emit(lines, "END:VEVENT");
emit(lines, "END:VCALENDAR");
return `${lines.join(CRLF)}${CRLF}`;
}
export type {
BuildVCalendarInput,
IcsAttendee,
IcsAttendeePartStat,
IcsEventStatus,
} from "./ics-builder.types.js";

View File

@@ -0,0 +1,53 @@
import type { Event } from "../../schemas/event-schemas.js";
import type { Attendee, RsvpStatus } from "../../schemas/rsvp-schemas.js";
/**
* VEVENT `STATUS` value per RFC 5545 §3.8.1.11. The plugin's event
* status integer is mapped to one of these three strings.
*/
export type IcsEventStatus = "TENTATIVE" | "CONFIRMED" | "CANCELLED";
/**
* `PARTSTAT` value per RFC 5545 §3.2.12. Mapped from the RSVP status.
*/
export type IcsAttendeePartStat =
| "ACCEPTED"
| "TENTATIVE"
| "DECLINED"
| "NEEDS-ACTION";
/**
* Attendee row consumed by `buildVCalendar` when emitting the
* `?with=rsvps` variant. The plugin already ships `Attendee` on
* `@suaveplan/api-plugin-events/schemas/rsvp-schemas`; this type is a
* thin alias so the ICS builder's public contract is self-contained.
*/
export type IcsAttendee = Pick<
Attendee,
"id" | "user" | "user_name" | "user_email"
> & {
/**
* Already-typed RSVP status. The builder converts this to a
* `PARTSTAT=<X>` parameter on the ATTENDEE line.
*/
status: RsvpStatus;
};
/** Input to `buildVCalendar`. */
export interface BuildVCalendarInput {
/**
* Event document, already loaded from the repository. All required
* VEVENT fields are sourced from this object's surface.
*/
readonly event: Event;
/**
* When provided, the builder emits one `ATTENDEE` line per row.
* Treated as the `?with=rsvps` variant. Omit for the base variant.
*/
readonly attendees?: ReadonlyArray<IcsAttendee>;
/**
* Wall-clock value in milliseconds since the Unix epoch, used to
* populate the `DTSTAMP` line. Injected so tests are byte-stable.
*/
readonly nowMs: number;
}

View File

@@ -0,0 +1,14 @@
export {
buildVCalendar,
escapeText,
foldLine,
formatDateUtc,
mapAttendeeStatus,
mapStatus,
} from "./ics-builder.js";
export type {
BuildVCalendarInput,
IcsAttendee,
IcsAttendeePartStat,
IcsEventStatus,
} from "./ics-builder.types.js";

View File

@@ -0,0 +1,107 @@
// Side-effect imports — surface the error-codes registry + the
// service-registry augmentation to every consumer importing the
// package barrel.
import "./error-codes/error-codes.js";
import "./services/registry-augmentation.js";
// Error codes
export {
EVENTS_PLUGIN_CODES,
EventsPluginError,
type EventsPluginErrorCode,
} from "./error-codes/error-codes.js";
// ICS export (RFC 5545)
export {
buildVCalendar,
escapeText,
foldLine,
formatDateUtc,
mapAttendeeStatus,
mapStatus,
} from "./ics/ics-builder/ics-builder.js";
export type {
BuildVCalendarInput,
IcsAttendee,
IcsAttendeePartStat,
IcsEventStatus,
} from "./ics/ics-builder/ics-builder.types.js";
// OTel
export {
type EventsInstruments,
getEventsInstruments,
resetEventsInstruments,
} from "./otel/instruments.js";
// Plugin
export { createEventsPlugin } from "./plugin/plugin.js";
// Repositories
export { EventRepository } from "./repositories/event-repository/event-repository.js";
export type {
EventRepositoryConfig,
IEventRepository,
} from "./repositories/event-repository/event-repository.types.js";
export { RsvpRepository } from "./repositories/rsvp-repository/rsvp-repository.js";
export type {
IRsvpRepository,
RsvpRepositoryConfig,
} from "./repositories/rsvp-repository/rsvp-repository.types.js";
// Routes
export {
createEventRoutes,
type EventsRoutesVariables,
} from "./routes/events-routes/events-routes.js";
export { createIcsExportRoutes } from "./routes/ics-export/ics-export.js";
export type { IcsExportRoutesConfig } from "./routes/ics-export/ics-export.types.js";
// Schemas
export {
type AttendeeCount,
AttendeeCountSchema,
type CreateEvent,
CreateEventSchema,
type Event,
type EventIdParam,
EventIdParamSchema,
type EventListResponse,
EventListResponseSchema,
type EventLocation,
EventLocationSchema,
type EventQuery,
EventQuerySchema,
EventSchema,
EventStatusSchema,
type UpdateEvent,
UpdateEventSchema,
} from "./schemas/event-schemas.js";
export {
type EventsPluginConfig,
type EventsPluginConfigInput,
EventsPluginOptionsSchema,
} from "./schemas/options.js";
export {
type Attendee,
type AttendeeListResponse,
AttendeeListResponseSchema,
type AttendeeQuery,
AttendeeQuerySchema,
AttendeeSchema,
type CreateRsvp,
CreateRsvpSchema,
type InviteResponse,
InviteResponseSchema,
type InviteUsers,
InviteUsersSchema,
type Rsvp,
type RsvpResponse,
RsvpResponseSchema,
RsvpSchema,
type RsvpStatus,
RsvpStatusSchema,
} from "./schemas/rsvp-schemas.js";
// Types
export type {
EventRoutesConfig,
EventsPluginEvents,
EventsRouteEvent,
} from "./types/types.js";

View File

@@ -0,0 +1,5 @@
export {
type EventsInstruments,
getEventsInstruments,
resetEventsInstruments,
} from "./instruments.js";

View File

@@ -0,0 +1,119 @@
# instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bundles the seven OpenTelemetry counters emitted by
`@suaveplan/api-plugin-events`. Every counter is lazily built on the
first `getEventsInstruments()` call and cached against the global
`MeterProvider` in effect at that point. Tests that install their own
in-memory `MeterProvider` call `resetEventsInstruments()` before
re-resolving the bundle so the counters bind to the freshly-installed
provider.
The seven instruments form the canonical observability surface for
the plugin: they count successful event lifecycle events (create /
update / delete), RSVP writes partitioned by status, invitations sent,
ICS exports served partitioned by `with` variant, and ICS export
failures partitioned by reason.
Histograms (e.g. route duration) are intentionally not part of the
0.1 surface; the canonical histogram for HTTP routes lives in the
host `@suaveplan/api` middleware layer.
## Features
- Seven `Counter`s emitted from the route handlers:
`events.created_total`, `events.updated_total`,
`events.deleted_total`, `events.rsvps_written_total{status}`,
`events.invitations_sent_total`, `events.ics.served_total{with}`,
`events.ics.failed_total{reason}`.
- Lazily built and cached — no instrument is constructed before the
first `getEventsInstruments()` call.
- `resetEventsInstruments()` clears the cache so tests can re-bind
against a fresh `MeterProvider`.
- Stable name shape (`events.*_total`) for OTel collector scraping.
## Usage: Basic Example
```ts
import { getEventsInstruments } from "@suaveplan/api-plugin-events";
const inst = getEventsInstruments();
inst.eventsCreatedTotal.add(1);
inst.rsvpsWrittenTotal.add(1, { status: "going" });
```
## Usage: Advanced Example
```ts
import { metrics } from "@opentelemetry/api";
import {
MeterProvider,
PeriodicExportingMetricReader,
InMemoryMetricExporter,
AggregationTemporality,
} from "@opentelemetry/sdk-metrics";
import {
getEventsInstruments,
resetEventsInstruments,
} from "@suaveplan/api-plugin-events";
// Install a fresh in-memory exporter for assertions
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const provider = new MeterProvider({
readers: [new PeriodicExportingMetricReader({ exporter })],
});
metrics.setGlobalMeterProvider(provider);
resetEventsInstruments();
// Drive a counter and flush
getEventsInstruments().invitationsSentTotal.add(3);
await provider.forceFlush();
const snapshot = exporter.getMetrics();
// snapshot now contains `events.invitations_sent_total` with value 3
```
## API Reference
### `interface EventsInstruments`
- `eventsCreatedTotal: Counter` — incremented on every successful `POST /`.
- `eventsUpdatedTotal: Counter` — incremented on every successful `PUT /:id`.
- `eventsDeletedTotal: Counter` — incremented on every successful `DELETE /:id`.
- `rsvpsWrittenTotal: Counter` — incremented on every successful
`POST /:id/rsvp`, labelled by the new status (`going` /
`maybe` / `invited` / `not_going`).
- `invitationsSentTotal: Counter` — incremented by the count of
newly-created invitations on every successful `POST /:id/invite`.
- `icsServedTotal: Counter` — incremented on every successful
`GET /:id/ics` response. Labelled by `with` (`"none"` or `"rsvps"`).
- `icsFailedTotal: Counter` — incremented on every error response
served by the ICS route. Labelled by `reason` (`"not_found"`,
`"forbidden"`, `"unauthenticated"`, or `"invalid_query"`).
### `getEventsInstruments(): EventsInstruments`
Lazily build and cache the instrument bundle. Repeated calls return
the same object reference.
### `resetEventsInstruments(): void`
Drop the cached bundle. The next `getEventsInstruments()` call
rebinds against whatever `MeterProvider` is in effect.
## Implementation Notes
The bundle uses singleton caching because OTel `Counter` objects are
themselves bound to the meter that created them — calling
`meter.createCounter("events.created_total")` twice does not produce a
new metric, but it does produce a new JavaScript object that is
slightly more expensive than the cached reference. The lazy
construction also lets the polyfill in `tests/setup/polyfills.ts`
install a `MeterProvider` before any source module that touches
metrics imports is loaded, which is necessary because OTel binds the
meter to the active provider at counter-creation time.

View File

@@ -0,0 +1,78 @@
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../tests/setup/polyfills.js";
import { getEventsInstruments, resetEventsInstruments } from "./instruments.js";
afterEach(() => {
resetEventsInstruments();
});
describe("getEventsInstruments", () => {
it("returns the same bundle on repeated calls", { timeout: 5000 }, () => {
resetEventsInstruments();
const a = getEventsInstruments();
const b = getEventsInstruments();
expect(a).toBe(b);
});
it("exposes seven named counters", { timeout: 5000 }, () => {
resetEventsInstruments();
const inst = getEventsInstruments();
expect(inst.eventsCreatedTotal).toBeDefined();
expect(inst.eventsUpdatedTotal).toBeDefined();
expect(inst.eventsDeletedTotal).toBeDefined();
expect(inst.rsvpsWrittenTotal).toBeDefined();
expect(inst.invitationsSentTotal).toBeDefined();
expect(inst.icsServedTotal).toBeDefined();
expect(inst.icsFailedTotal).toBeDefined();
});
it("exposes ics served and failed counters", {
timeout: 30000,
}, async () => {
resetEventsInstruments();
const inst = getEventsInstruments();
inst.icsServedTotal.add(1, { with: "none" });
inst.icsServedTotal.add(1, { with: "rsvps" });
inst.icsFailedTotal.add(1, { reason: "not_found" });
const snapshots = await flushTestMetrics();
const all = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) => sm.metrics)
);
const names = all.map((m) => m.descriptor.name);
expect(names).toContain("events.ics.served_total");
expect(names).toContain("events.ics.failed_total");
});
it("emits events.created_total when a create succeeds", {
timeout: 30000,
}, async () => {
resetEventsInstruments();
const inst = getEventsInstruments();
inst.eventsCreatedTotal.add(1);
inst.eventsCreatedTotal.add(2);
inst.eventsUpdatedTotal.add(1);
inst.eventsDeletedTotal.add(1);
inst.rsvpsWrittenTotal.add(1, { status: "going" });
inst.invitationsSentTotal.add(4);
const snapshots = await flushTestMetrics();
const all = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) => sm.metrics)
);
const names = all.map((m) => m.descriptor.name);
expect(names).toContain("events.created_total");
expect(names).toContain("events.updated_total");
expect(names).toContain("events.deleted_total");
expect(names).toContain("events.rsvps_written_total");
expect(names).toContain("events.invitations_sent_total");
});
it("resetEventsInstruments rebinds against a fresh meter", {
timeout: 5000,
}, () => {
resetEventsInstruments();
const a = getEventsInstruments();
resetEventsInstruments();
const b = getEventsInstruments();
expect(a).not.toBe(b);
});
});

View File

@@ -0,0 +1,83 @@
import { type Counter, metrics } from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the events plugin.
*
* - `eventsCreatedTotal` — incremented on every successful `POST /`.
* - `eventsUpdatedTotal` — incremented on every successful `PUT /:id`.
* - `eventsDeletedTotal` — incremented on every successful
* `DELETE /:id`.
* - `rsvpsWrittenTotal{status}` — incremented on every successful
* `POST /:id/rsvp`, labelled by the new status.
* - `invitationsSentTotal` — incremented by the count of newly-created
* invitations on every successful `POST /:id/invite`.
* - `icsServedTotal{with}` — incremented on every successful
* `GET /:id/ics` response; the `with` label is `"none"` (base
* variant) or `"rsvps"`.
* - `icsFailedTotal{reason}` — incremented on every error response
* served by the ICS route; the `reason` label is `"not_found"`,
* `"forbidden"`, `"unauthenticated"`, or `"invalid_query"`.
*/
export interface EventsInstruments {
readonly eventsCreatedTotal: Counter;
readonly eventsUpdatedTotal: Counter;
readonly eventsDeletedTotal: Counter;
readonly rsvpsWrittenTotal: Counter;
readonly invitationsSentTotal: Counter;
readonly icsServedTotal: Counter;
readonly icsFailedTotal: Counter;
}
let cached: EventsInstruments | undefined;
/**
* Lazily build (and cache) the events instruments bundle against the
* current global `MeterProvider`. Call {@link resetEventsInstruments}
* in test setup to force a re-bind against a freshly-installed
* provider.
*/
export function getEventsInstruments(): EventsInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-plugin-events");
cached = {
eventsCreatedTotal: meter.createCounter("events.created_total", {
description: "Total events successfully created via POST /",
}),
eventsUpdatedTotal: meter.createCounter("events.updated_total", {
description: "Total events successfully updated via PUT /:id",
}),
eventsDeletedTotal: meter.createCounter("events.deleted_total", {
description: "Total events successfully deleted via DELETE /:id",
}),
rsvpsWrittenTotal: meter.createCounter("events.rsvps_written_total", {
description:
"Total RSVPs written via POST /:id/rsvp, partitioned by status",
}),
invitationsSentTotal: meter.createCounter(
"events.invitations_sent_total",
{
description:
"Total invitations created via POST /:id/invite (excludes idempotent skips)",
}
),
icsServedTotal: meter.createCounter("events.ics.served_total", {
description:
"Total successful GET /:id/ics responses, partitioned by `with`",
}),
icsFailedTotal: meter.createCounter("events.ics.failed_total", {
description:
"Total error responses on GET /:id/ics, partitioned by `reason`",
}),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next
* {@link getEventsInstruments} call rebinds against the global
* `MeterProvider` in effect at that point. Tests call this after
* installing a fresh `MeterProvider`.
*/
export function resetEventsInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1,112 @@
# plugin
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `createEventsPlugin`, the factory that returns the
`PluginDescriptor<EventsPluginConfig>` consumed by `@suaveplan/api`'s
plugin registry. The factory Zod-validates options at the boundary,
constructs the runtime closure, and returns the four lifecycle hooks
(`onInit`, `onReady`, `onClose`) that the registry's topological
sorter wires up after the `database` dependency has booted.
The lifecycle is intentionally split: `onInit` reads the `db` slot
and registers the two repositories so downstream plugins (search,
audit, notifications) can resolve `eventRepository` /
`rsvpRepository` during their own `onInit`; `onReady` is the latest
phase the registry runs, so the `app` slot is guaranteed-populated
and the route mount is the last side effect before the host starts
listening.
## Features
- Zod-validated config at construction time — invalid options raise
`EventsPluginError(INVALID_CONFIG)` before the descriptor leaves
the factory.
- Lifecycle dedup — `onReady` refuses to mount when the configured
`basePath` already has routes registered, raising
`EventsPluginError(ROUTE_PREFIX_COLLISION)` instead of silently
shadowing the first mount.
- Forwards typed route events to the host's shared
`ctx.eventBus` (fixes the upstream "local emitter" smell flagged in
`enterprise/api-plugin-events.md`).
- `onClose` clears repository references so the GC can collect them.
## Usage: Basic Example
```ts
import { createEventsPlugin } from "@suaveplan/api-plugin-events";
import { quickstart } from "@suaveplan/api";
import { createDatabasePlugin } from "@suaveplan/api-plugin-database";
const app = await quickstart({
plugins: [
createDatabasePlugin({
/* ... */
}),
createEventsPlugin(), // mounts under /events by default
],
});
```
## Usage: Advanced Example
```ts
import { createEventsPlugin } from "@suaveplan/api-plugin-events";
const plugin = createEventsPlugin({
basePath: "/v1/events",
enableNotifications: false,
maxInvitesPerRequest: 25,
});
// Inspect the descriptor before handing it to the registry
console.log(plugin.name); // "events"
console.log(plugin.dependencies); // ["database"]
console.log(plugin.defaults.maxInvitesPerRequest); // 25
// Subscribe to lifecycle and domain events on the shared bus:
host.eventBus.on("events:ready", () => {
/* routes are now mounted */
});
host.eventBus.on("event:created", ({ eventId, creatorId }) => {
/* fan out to notifications, search index, audit log */
});
```
## API Reference
### `createEventsPlugin(options?: EventsPluginConfigInput): PluginDescriptor<EventsPluginConfig>`
Builds the plugin descriptor.
- **options** — Partial `EventsPluginConfig`. Each field has a
default; passing `{}` (or no argument) produces the default
config (`/events`, `enableNotifications: false`,
`maxInvitesPerRequest: 50`).
- **throws** `EventsPluginError(INVALID_CONFIG)` if `options` fails
`EventsPluginOptionsSchema`.
### Lifecycle hooks (attached to the returned descriptor)
- `onInit(ctx)` — pulls `db` from the registry, constructs the
repositories, registers `eventRepository` and `rsvpRepository`,
emits `events:initialized`.
- `onReady(ctx)` — pulls `app`, builds routes via
`createEventRoutes`, mounts under `config.basePath`, emits
`events:ready`.
- `onClose()` — clears repository refs.
## Implementation Notes
The plugin's `dependencies: ["database"]` declaration makes the
suaveplan registry topo-sort the events plugin strictly after the
database plugin. Without that ordering, `onInit` would fail because
`ctx.getService("db")` would resolve to `undefined`. The `app` slot
does not need to be declared as a dependency because every host
seeds it during registry construction (per the foundation patch
shipped on `2026-05-17`); the runtime check inside `onReady` is
defensive against custom hosts that don't pre-seed.

View File

@@ -0,0 +1,324 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
} from "../error-codes/error-codes.js";
import type { EventsPluginConfig } from "../schemas/options.js";
import { createEventsPlugin } from "./plugin.js";
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
function fakeDb(): SurrealAdapter {
return {
async query() {
return [];
},
} as unknown as SurrealAdapter;
}
function buildCtx<TConfig>(
config: TConfig,
services: Map<string, unknown> = new Map()
) {
return createPluginContext<TConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config,
services,
});
}
describe("createEventsPlugin", () => {
it("createEventsPlugin returns descriptor with defaults", {
timeout: 5000,
}, () => {
const plugin = createEventsPlugin({ basePath: "/v1/events" });
expect(plugin.name).toBe("events");
expect(plugin.dependencies).toEqual(["database"]);
const defaults = plugin.defaults as EventsPluginConfig;
expect(defaults.basePath).toBe("/v1/events");
expect(defaults.maxInvitesPerRequest).toBe(50);
});
it("createEventsPlugin called with no args uses all defaults", {
timeout: 5000,
}, () => {
const plugin = createEventsPlugin();
const defaults = plugin.defaults as EventsPluginConfig;
expect(defaults.basePath).toBe("/events");
expect(defaults.enableNotifications).toBe(false);
expect(defaults.maxInvitesPerRequest).toBe(50);
});
it("createEventsPlugin throws INVALID_CONFIG for bad options", {
timeout: 5000,
}, () => {
try {
createEventsPlugin({ maxInvitesPerRequest: -1 } as never);
throw new Error("expected throw");
} catch (err) {
expect(err).toBeInstanceOf(EventsPluginError);
expect((err as EventsPluginError).code).toBe(
EVENTS_PLUGIN_CODES.INVALID_CONFIG
);
}
});
});
describe("createEventsPlugin lifecycle", () => {
it("onInit registers eventRepository and rsvpRepository", {
timeout: 5000,
}, async () => {
const services = new Map<string, unknown>([["db", fakeDb()]]);
const plugin = createEventsPlugin({});
const ctx = buildCtx(plugin.defaults as EventsPluginConfig, services);
await plugin.onInit?.(ctx);
expect(ctx.getService("eventRepository")).toBeDefined();
expect(ctx.getService("rsvpRepository")).toBeDefined();
});
it("onInit throws SERVICE_NOT_AVAILABLE without db", {
timeout: 5000,
}, async () => {
const plugin = createEventsPlugin({});
const ctx = buildCtx(plugin.defaults as EventsPluginConfig);
await expect(plugin.onInit?.(ctx)).rejects.toBeInstanceOf(
EventsPluginError
);
});
it("onReady mounts routes under basePath", { timeout: 5000 }, async () => {
const app = new OpenAPIHono();
const services = new Map<string, unknown>([
["db", fakeDb()],
["app", app],
]);
const plugin = createEventsPlugin({ basePath: "/events" });
const ctx = buildCtx(plugin.defaults as EventsPluginConfig, services);
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
const paths = (
app as unknown as { routes: ReadonlyArray<{ path: string }> }
).routes.map((r) => r.path);
expect(paths.some((p) => p.startsWith("/events"))).toBe(true);
});
it("onReady throws SERVICE_NOT_AVAILABLE before onInit", {
timeout: 5000,
}, async () => {
const app = new OpenAPIHono();
const services = new Map<string, unknown>([["app", app]]);
const plugin = createEventsPlugin({});
const ctx = buildCtx(plugin.defaults as EventsPluginConfig, services);
await expect(plugin.onReady?.(ctx)).rejects.toBeInstanceOf(
EventsPluginError
);
});
it("onReady throws SERVICE_NOT_AVAILABLE without app", {
timeout: 5000,
}, async () => {
const services = new Map<string, unknown>([["db", fakeDb()]]);
const plugin = createEventsPlugin({});
const ctx = buildCtx(plugin.defaults as EventsPluginConfig, services);
await plugin.onInit?.(ctx);
await expect(plugin.onReady?.(ctx)).rejects.toBeInstanceOf(
EventsPluginError
);
});
it("onReady throws ROUTE_PREFIX_COLLISION on double-mount", {
timeout: 5000,
}, async () => {
const app = new OpenAPIHono();
const services = new Map<string, unknown>([
["db", fakeDb()],
["app", app],
]);
const plugin = createEventsPlugin({ basePath: "/events" });
const ctx = buildCtx(plugin.defaults as EventsPluginConfig, services);
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
await expect(plugin.onReady?.(ctx)).rejects.toBeInstanceOf(
EventsPluginError
);
});
it("onClose clears runtime state", { timeout: 5000 }, async () => {
const services = new Map<string, unknown>([["db", fakeDb()]]);
const plugin = createEventsPlugin({});
const ctx = buildCtx(plugin.defaults as EventsPluginConfig, services);
await plugin.onInit?.(ctx);
await plugin.onClose?.(ctx);
// After close, calling onReady should fail because repos are cleared
const app = new OpenAPIHono();
services.set("app", app);
await expect(plugin.onReady?.(ctx)).rejects.toBeInstanceOf(
EventsPluginError
);
});
it("onInit emits events:initialized on the bus", {
timeout: 5000,
}, async () => {
const services = new Map<string, unknown>([["db", fakeDb()]]);
const plugin = createEventsPlugin({});
const bus = new EventBus();
const seen: string[] = [];
bus.on("events:initialized", () => {
seen.push("init");
});
const ctx = createPluginContext({
logger: noopLogger,
eventBus: bus,
signal: new AbortController().signal,
config: plugin.defaults as EventsPluginConfig,
services,
});
await plugin.onInit?.(ctx);
expect(seen).toContain("init");
});
it("onReady emits events:ready and forwards route events", {
timeout: 10000,
}, async () => {
const app = new OpenAPIHono<{
Variables: { user: { id: string } | null };
}>();
// Pre-mount user middleware so the routes pass auth.
app.use(async (c, next) => {
c.set("user", { id: "user:1" });
await next();
});
const services = new Map<string, unknown>([
["db", fakeDb()],
["app", app],
]);
const plugin = createEventsPlugin({});
const bus = new EventBus();
const seen: string[] = [];
bus.on("events:ready", () => {
seen.push("ready");
});
bus.on("event:created", () => {
seen.push("event:created");
});
const ctx = createPluginContext({
logger: noopLogger,
eventBus: bus,
signal: new AbortController().signal,
config: plugin.defaults as EventsPluginConfig,
services,
});
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
expect(seen).toContain("ready");
// Fire a route that triggers emitEvent so the plugin's
// forwarder callback executes and forwards onto the bus.
const res = await app.request("/events", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
}),
});
// The fake `db.query` returns `[]`, so the repository's
// create() raises EVENT_CREATE_FAILED — but to verify the
// forwarder is wired, we instead create the route on a
// real-enough db. Re-mock so create succeeds.
expect([201, 500]).toContain(res.status);
});
it("onReady forwarder forwards route events onto eventBus", {
timeout: 10000,
}, async () => {
const app = new OpenAPIHono<{
Variables: { user: { id: string } | null };
}>();
app.use(async (c, next) => {
c.set("user", { id: "user:1" });
await next();
});
// db.query returns a non-empty row so create succeeds and
// emitEvent is invoked.
const fakeRow = {
id: "event:1",
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const db = {
async query() {
return [fakeRow];
},
} as unknown as SurrealAdapter;
const services = new Map<string, unknown>([
["db", db],
["app", app],
]);
const plugin = createEventsPlugin({});
const bus = new EventBus();
const seen: string[] = [];
bus.on("event:created", () => {
seen.push("event:created");
});
const ctx = createPluginContext({
logger: noopLogger,
eventBus: bus,
signal: new AbortController().signal,
config: plugin.defaults as EventsPluginConfig,
services,
});
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
const res = await app.request("/events", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
}),
});
expect(res.status).toBe(201);
expect(seen).toContain("event:created");
});
it("onReady mounts the ICS export route", {
timeout: 5000,
}, async () => {
const app = new OpenAPIHono();
const services = new Map<string, unknown>([
["db", fakeDb()],
["app", app],
]);
const plugin = createEventsPlugin({ basePath: "/events" });
const ctx = buildCtx(plugin.defaults as EventsPluginConfig, services);
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
const paths = (
app as unknown as { routes: ReadonlyArray<{ path: string }> }
).routes.map((r) => r.path);
// The ICS route is mounted under the same basePath.
expect(paths.some((p) => p.includes("/ics"))).toBe(true);
});
});

View File

@@ -0,0 +1,154 @@
import {
definePlugin,
type PluginContext,
type PluginDescriptor,
} from "@suaveplan/api-plugin";
import "../services/registry-augmentation.js";
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
} from "../error-codes/error-codes.js";
import { EventRepository } from "../repositories/event-repository/event-repository.js";
import { RsvpRepository } from "../repositories/rsvp-repository/rsvp-repository.js";
import { createEventRoutes } from "../routes/events-routes/events-routes.js";
import { createIcsExportRoutes } from "../routes/ics-export/ics-export.js";
import {
type EventsPluginConfig,
type EventsPluginConfigInput,
EventsPluginOptionsSchema,
} from "../schemas/options.js";
import type { EventsRouteEvent } from "../types/types.js";
interface PluginRuntime {
eventRepository: EventRepository | null;
rsvpRepository: RsvpRepository | null;
}
/**
* Build the plugin descriptor for `@suaveplan/api-plugin-events`.
*
* Lifecycle:
*
* 1. **Construction-time** Zod validation of `options`. Invalid input
* surfaces `EventsPluginError(INVALID_CONFIG)` with the `ZodError`
* as `cause`.
* 2. **`onInit`** — pulls the `db` adapter from the host's
* `ServiceRegistry`, constructs the two repositories, registers
* them under `eventRepository` / `rsvpRepository` slots, and
* emits `events:initialized` on the shared `eventBus`.
* 3. **`onReady`** — pulls the `app` (`OpenAPIHono`) from the
* registry, builds the route tree via `createEventRoutes`, and
* mounts it under `config.basePath`. Refuses to double-mount.
* Emits `events:ready`.
* 4. **`onClose`** — clears repository references so the GC can
* collect them.
*/
export function createEventsPlugin(
options: EventsPluginConfigInput = {}
): PluginDescriptor<EventsPluginConfig> {
const parsed = EventsPluginOptionsSchema.safeParse(options);
if (!parsed.success) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.INVALID_CONFIG,
"createEventsPlugin received invalid options",
{ cause: parsed.error, context: { issues: parsed.error.issues } }
);
}
const config = parsed.data;
const runtime: PluginRuntime = {
eventRepository: null,
rsvpRepository: null,
};
return definePlugin<EventsPluginConfig>({
name: "events",
version: "0.1.0",
description:
"Event-management plugin: SurrealDB-backed event + RSVP CRUD, 9 OpenAPI routes, EventBus emission, 12 error codes, 5 OTel instruments.",
dependencies: ["database"],
tags: ["events", "calendar", "rsvp"],
defaults: config,
onInit: async (ctx) => runOnInit(ctx, runtime),
onReady: async (ctx) => runOnReady(ctx, config, runtime),
onClose: async () => runOnClose(runtime),
});
}
async function runOnInit(
ctx: PluginContext<EventsPluginConfig>,
runtime: PluginRuntime
): Promise<void> {
const db = ctx.getService("db");
if (db === undefined) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"events plugin requires the `db` service in ServiceRegistry — ensure @suaveplan/api-plugin-database is loaded first",
{ context: { missing: ["db"] } }
);
}
runtime.eventRepository = new EventRepository({ db });
runtime.rsvpRepository = new RsvpRepository({ db });
ctx.registerService("eventRepository", runtime.eventRepository);
ctx.registerService("rsvpRepository", runtime.rsvpRepository);
ctx.eventBus.emit("events:initialized", undefined);
}
async function runOnReady(
ctx: PluginContext<EventsPluginConfig>,
config: EventsPluginConfig,
runtime: PluginRuntime
): Promise<void> {
if (runtime.eventRepository === null || runtime.rsvpRepository === null) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"onReady invoked before onInit completed",
{ context: { phase: "onReady" } }
);
}
const app = ctx.getService("app");
if (app === undefined) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.SERVICE_NOT_AVAILABLE,
"events plugin requires the `app` service in ServiceRegistry",
{ context: { missing: ["app"] } }
);
}
const existing = (
app as unknown as { routes?: ReadonlyArray<{ path: string }> }
).routes;
if (
Array.isArray(existing) &&
existing.some((r) => r.path.startsWith(`${config.basePath}/`))
) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.ROUTE_PREFIX_COLLISION,
`Route prefix '${config.basePath}' already has registered routes`,
{ context: { prefix: config.basePath } }
);
}
const routes = createEventRoutes({
eventRepository: runtime.eventRepository,
rsvpRepository: runtime.rsvpRepository,
maxInvitesPerRequest: config.maxInvitesPerRequest,
emitEvent: (event: EventsRouteEvent) => {
ctx.eventBus.emit(event.type, event.payload);
},
});
app.route(config.basePath, routes);
const icsRoutes = createIcsExportRoutes({
eventRepository: runtime.eventRepository,
rsvpRepository: runtime.rsvpRepository,
});
app.route(config.basePath, icsRoutes);
ctx.eventBus.emit("events:ready", undefined);
}
async function runOnClose(runtime: PluginRuntime): Promise<void> {
runtime.eventRepository = null;
runtime.rsvpRepository = null;
}

View File

@@ -0,0 +1,120 @@
# event-repository
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
SurrealDB-backed implementation of the `IEventRepository` contract.
The repository owns event-entity CRUD: list / get / create / update /
delete, plus the auxiliary `isCreator` ownership check and the
bucketed `updateAttendeeCount` counter mutation that the RSVP routes
call after a successful upsert. Every SurrealQL statement is
parameterised with positional `$p0`, `$p1`, … placeholders to ride
over the `DatabaseAdapter.query(sql, params: readonly unknown[])`
surface which converts the array into the `{p0, p1, …}` record shape
the surrealdb.js driver expects.
The class also handles cross-shape creator id extraction: SurrealDB
record links can deserialise as a `RecordId`-like object
(`{ tb, id }`), a bare string (`user:1`), or an object with only an
`id` property (depending on how the row was inserted); the
`extractCreatorId` private method normalises all three forms.
## Features
- 7-method surface mirroring the upstream parity: `findAll`,
`findById`, `create`, `update`, `delete`, `isCreator`,
`updateAttendeeCount`.
- Idempotent ID normalisation — every method accepts bare ids
(`abc`) and prefixed ids (`event:abc`) interchangeably.
- Pagination (`limit`, `offset`) and filtering (`status`, `upcoming`,
`past`) on `findAll`.
- Bucketed attendee counters — `updateAttendeeCount` interpolates the
arithmetic literally (SurrealDB forbids bound vars on the right of
`field + X`) but sanitises the increment to an integer via
`Math.trunc(Number.isFinite(...))`.
- Dedicated error codes — `EVENT_CREATE_FAILED` /
`EVENT_UPDATE_FAILED` raised when the adapter returns no row.
## Usage: Basic Example
```ts
import { EventRepository } from "@suaveplan/api-plugin-events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
const db = new SurrealAdapter({
/* ... */
});
await db.connect();
const repo = new EventRepository({ db });
const event = await repo.create("user:1", {
title: "Bun launch party",
start_time: "2026-06-01T18:00:00Z",
end_time: "2026-06-01T22:00:00Z",
timezone: "UTC",
});
console.log(event.id); // "event:..."
```
## Usage: Advanced Example
```ts
import { EventRepository } from "@suaveplan/api-plugin-events";
const repo = new EventRepository({ db });
// Filtered listing with explicit pagination.
const upcoming = await repo.findAll({
upcoming: true,
limit: 50,
offset: 0,
});
// Ownership check before allowing a mutation.
const owns = await repo.isCreator("event:1", "user:1");
if (!owns) throw new Error("403 — not creator");
// Bucketed counter bump after a successful RSVP.
await repo.updateAttendeeCount("event:1", "going", 1);
```
## API Reference
### `class EventRepository implements IEventRepository`
Constructor: `new EventRepository({ db: SurrealAdapter })`.
- `findAll(query?: EventQuery): Promise<Event[]>` — paginated list.
- `findById(id: string): Promise<Event | null>` — `null` when missing.
- `create(userId: string, data: CreateEvent): Promise<Event>` —
throws `EVENT_CREATE_FAILED` on empty adapter response.
- `update(id: string, data: UpdateEvent): Promise<Event>` —
throws `EVENT_UPDATE_FAILED` on empty adapter response.
- `delete(id: string): Promise<void>` — issues `DELETE event:id`.
- `isCreator(eventId: string, userId: string): Promise<boolean>`.
- `updateAttendeeCount(eventId, status, increment): Promise<void>` —
no-op for `"not_going"`.
### `interface IEventRepository`
Public contract — see `event-repository.types.ts`.
### `interface EventRepositoryConfig`
`{ db: SurrealAdapter }`.
## Implementation Notes
The `updateAttendeeCount` method intentionally interpolates the
integer increment rather than binding it as a positional parameter
because SurrealDB does not currently allow `$var` on the right-hand
side of an in-place arithmetic update (`field = field + $var`). The
method sanitises the value with `Math.trunc(Number.isFinite(...))`
so an accidental NaN / Infinity / decimal does not break the SET
clause. SurrealDB's record-link cell can deserialise as one of three
shapes depending on driver version and how the row was inserted; the
private `extractCreatorId` enumerates all three so the
`isCreator` check survives format drift.

View File

@@ -0,0 +1,364 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { EventsPluginError } from "../../error-codes/error-codes.js";
import { EventRepository } from "./event-repository.js";
interface QueryCall {
sql: string;
params: readonly unknown[] | undefined;
}
function makeFakeDb(rows: unknown[][]): {
db: SurrealAdapter;
calls: QueryCall[];
} {
const calls: QueryCall[] = [];
let i = 0;
const db = {
async query<T>(sql: string, params?: readonly unknown[]) {
calls.push({ sql, params });
const row = rows[i++] ?? [];
return row as T[];
},
} as unknown as SurrealAdapter;
return { db, calls };
}
afterEach(() => {
// nothing — fixtures are fresh per `it`
});
describe("EventRepository.findAll", () => {
it("findAll applies status and upcoming filters", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[]]);
await new EventRepository({ db }).findAll({
status: 1,
upcoming: true,
limit: 10,
offset: 5,
});
const { sql, params } = calls[0]!;
expect(sql).toContain("status = $p0");
expect(sql).toContain("end_time > time::now()");
expect(sql).toContain("LIMIT $p1");
expect(sql).toContain("START $p2");
expect(params).toEqual([1, 10, 5]);
});
it("findAll past flag flips order DESC", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[]]);
await new EventRepository({ db }).findAll({
past: true,
limit: 20,
offset: 0,
});
expect(calls[0]!.sql).toContain("ORDER BY start_time DESC");
expect(calls[0]!.sql).toContain("end_time <= time::now()");
});
it("findAll returns empty array on empty rows", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[]]);
const result = await new EventRepository({ db }).findAll();
expect(result).toEqual([]);
});
it("findAll without options uses default limit/offset", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[]]);
await new EventRepository({ db }).findAll();
expect(calls[0]!.params).toEqual([20]);
});
});
describe("EventRepository.findById", () => {
it("findById returns row when present", { timeout: 5000 }, async () => {
const row = { id: "event:1", title: "T" };
const { db, calls } = makeFakeDb([[row]]);
const result = await new EventRepository({ db }).findById("1");
expect(calls[0]!.sql).toBe("SELECT * FROM event:1");
expect(result).toEqual(row);
});
it("findById returns null when not found", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[]]);
const result = await new EventRepository({ db }).findById("missing");
expect(result).toBeNull();
});
it("findById accepts already-prefixed id", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[]]);
await new EventRepository({ db }).findById("event:abc");
expect(calls[0]!.sql).toBe("SELECT * FROM event:abc");
});
});
describe("EventRepository.create", () => {
const data = {
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
timezone: "UTC",
status: 1,
};
it("create normalises bare user id", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[{ id: "event:1" }]]);
await new EventRepository({ db }).create("1", data);
expect(calls[0]!.sql).toContain("creator = user:1");
});
it("create passes optional fields through", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[{ id: "event:1" }]]);
await new EventRepository({ db }).create("user:1", {
...data,
description: "D",
location: { name: "Venue" },
});
const sql = calls[0]!.sql;
expect(sql).toContain("title = $p0");
expect(sql).toContain("description = $p1");
expect(sql).toContain("location = $p");
const params = calls[0]!.params!;
expect(params).toContain("D");
expect(params).toContainEqual({ name: "Venue" });
});
it("create raises EVENT_CREATE_FAILED on empty response", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[]]);
await expect(
new EventRepository({ db }).create("user:1", data)
).rejects.toBeInstanceOf(EventsPluginError);
});
it("create omits unset optional fields and falls back for required ones", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[{ id: "event:1" }]]);
await new EventRepository({ db }).create("user:1", {
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
} as never);
const sql = calls[0]!.sql;
expect(sql).not.toContain("description = $p");
expect(sql).not.toContain("location = $p");
expect(sql).toContain("timezone = $p");
expect(sql).toContain("status = $p");
const params = calls[0]!.params!;
expect(params[params.length - 1]).toBe(1);
});
it("create skips description when explicitly empty string", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[{ id: "event:1" }]]);
await new EventRepository({ db }).create("user:1", {
...data,
description: "",
});
expect(calls[0]!.sql).not.toContain("description = $p");
});
it("create includes description when non-empty", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[{ id: "event:1" }]]);
await new EventRepository({ db }).create("user:1", {
...data,
description: "D",
});
expect(calls[0]!.sql).toContain("description = $p");
});
});
describe("EventRepository.update", () => {
it("update only emits provided fields", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[{ id: "event:1" }]]);
await new EventRepository({ db }).update("1", { title: "X" });
expect(calls[0]!.sql).toContain("title = $p0");
expect(calls[0]!.sql).not.toContain("description");
expect(calls[0]!.params).toEqual(["X"]);
});
it("update threads every field when supplied", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[{ id: "event:1" }]]);
await new EventRepository({ db }).update("1", {
title: "T",
description: "D",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
timezone: "UTC",
location: { name: "V" },
status: 2,
});
const sql = calls[0]!.sql;
expect(sql).toContain("title = $p0");
expect(sql).toContain("description = $p1");
expect(sql).toContain("start_time = type::datetime($p2)");
expect(sql).toContain("end_time = type::datetime($p3)");
expect(sql).toContain("timezone = $p4");
expect(sql).toContain("location = $p5");
expect(sql).toContain("status = $p6");
expect(sql).toContain("updated_at = time::now()");
});
it("update without title omits the title clause", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[{ id: "event:1" }]]);
await new EventRepository({ db }).update("1", { description: "D" });
expect(calls[0]!.sql).not.toContain("title = $p");
expect(calls[0]!.sql).toContain("description = $p0");
});
it("update raises EVENT_UPDATE_FAILED on empty response", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[]]);
await expect(
new EventRepository({ db }).update("1", { title: "X" })
).rejects.toBeInstanceOf(EventsPluginError);
});
});
describe("EventRepository.delete", () => {
it("delete issues DELETE statement", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[]]);
await new EventRepository({ db }).delete("1");
expect(calls[0]!.sql).toBe("DELETE event:1");
});
});
describe("EventRepository.isCreator", () => {
it("isCreator handles RecordId object", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[{ creator: { tb: "user", id: "1" } }]]);
const result = await new EventRepository({ db }).isCreator(
"event:1",
"user:1"
);
expect(result).toBe(true);
});
it("isCreator handles bare string creator", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[{ creator: "user:1" }]]);
expect(
await new EventRepository({ db }).isCreator("event:1", "user:1")
).toBe(true);
});
it("isCreator handles object-with-id-only", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[{ creator: { id: "user:1" } }]]);
expect(
await new EventRepository({ db }).isCreator("event:1", "user:1")
).toBe(true);
});
it("isCreator handles non-object non-string creator", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[{ creator: 42 }]]);
expect(
await new EventRepository({ db }).isCreator("event:1", "user:42")
).toBe(true);
});
it("isCreator returns false when row empty", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[]]);
expect(
await new EventRepository({ db }).isCreator("event:1", "user:1")
).toBe(false);
});
it("isCreator returns false when row null", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[null]]);
expect(
await new EventRepository({ db }).isCreator("event:1", "user:1")
).toBe(false);
});
it("isCreator handles object without tb or id (falls through to String())", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[{ creator: { foo: "bar" } }]]);
// String({foo: "bar"}) → "[object Object]" → normalizeUserId → "user:[object Object]"
const result = await new EventRepository({ db }).isCreator(
"event:1",
"[object Object]"
);
expect(result).toBe(true);
});
it("isCreator returns false when creator does not match", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[{ creator: "user:1" }]]);
expect(
await new EventRepository({ db }).isCreator("event:1", "user:2")
).toBe(false);
});
});
describe("EventRepository.updateAttendeeCount", () => {
it("updateAttendeeCount no-ops for not_going", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([]);
await new EventRepository({ db }).updateAttendeeCount(
"event:1",
"not_going",
1
);
expect(calls).toHaveLength(0);
});
it("updateAttendeeCount increments going", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[]]);
await new EventRepository({ db }).updateAttendeeCount(
"event:1",
"going",
1
);
expect(calls[0]!.sql).toContain(
"attendee_count.going = attendee_count.going + 1"
);
});
it("updateAttendeeCount handles negative increments", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[]]);
await new EventRepository({ db }).updateAttendeeCount(
"event:1",
"maybe",
-1
);
expect(calls[0]!.sql).toContain(
"attendee_count.maybe = attendee_count.maybe + -1"
);
});
it("updateAttendeeCount sanitises non-finite increments to 0", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([[]]);
await new EventRepository({ db }).updateAttendeeCount(
"event:1",
"invited",
Number.NaN
);
expect(calls[0]!.sql).toContain(
"attendee_count.invited = attendee_count.invited + 0"
);
});
});

View File

@@ -0,0 +1,201 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
} from "../../error-codes/error-codes.js";
import type {
CreateEvent,
Event,
EventQuery,
UpdateEvent,
} from "../../schemas/event-schemas.js";
import type {
EventRepositoryConfig,
IEventRepository,
} from "./event-repository.types.js";
/**
* SurrealDB-backed implementation of {@link IEventRepository}.
*
* All SurrealQL placeholders are positional `$p0`, `$p1`, … to match
* the genesis `DatabaseAdapter.query(sql, params: readonly unknown[])`
* surface — the adapter rewrites positional arrays into `{p0, p1, …}`
* records before handing them to the surrealdb.js driver.
*/
export class EventRepository implements IEventRepository {
private db: SurrealAdapter;
constructor(config: EventRepositoryConfig) {
this.db = config.db;
}
/** Normalise event ID — ensure it carries the `event:` prefix. */
private normalizeEventId(id: string): string {
return id.includes(":") ? id : `event:${id}`;
}
/** Normalise user ID — ensure it carries the `user:` prefix. */
private normalizeUserId(id: string): string {
return id.includes(":") ? id : `user:${id}`;
}
/**
* Extract a stringified creator id from the raw row's `creator`
* cell. SurrealDB returns record links as `RecordId`-shaped
* objects (`{ tb, id }`), but a manually-inserted row may carry a
* bare string or an object with only `id`.
*/
private extractCreatorId(creator: unknown): string {
if (typeof creator === "string") return creator;
if (typeof creator === "object" && creator !== null) {
const obj = creator as Record<string, unknown>;
if ("tb" in obj && "id" in obj) {
return `${String(obj["tb"])}:${String(obj["id"])}`;
}
if ("id" in obj) return String(obj["id"]);
}
return String(creator);
}
async findAll(query?: EventQuery): Promise<Event[]> {
const limit = query?.limit ?? 20;
const offset = query?.offset ?? 0;
const conditions: string[] = [];
const params: unknown[] = [];
if (query?.status !== undefined) {
params.push(query.status);
conditions.push(`status = $p${params.length - 1}`);
}
if (query?.upcoming) conditions.push("end_time > time::now()");
if (query?.past) conditions.push("end_time <= time::now()");
let sql = "SELECT * FROM event";
if (conditions.length > 0) sql += ` WHERE ${conditions.join(" AND ")}`;
sql += ` ORDER BY start_time ${query?.past ? "DESC" : "ASC"}`;
params.push(limit);
sql += ` LIMIT $p${params.length - 1}`;
if (offset > 0) {
params.push(offset);
sql += ` START $p${params.length - 1}`;
}
const rows = await this.db.query<Event>(sql, params);
return rows;
}
async findById(id: string): Promise<Event | null> {
const eventId = this.normalizeEventId(id);
const rows = await this.db.query<Event>(`SELECT * FROM ${eventId}`);
return rows[0] ?? null;
}
async create(userId: string, data: CreateEvent): Promise<Event> {
const creator = this.normalizeUserId(userId);
const params: unknown[] = [];
const sets: string[] = [];
const pushSet = (clause: string, value: unknown): void => {
params.push(value);
sets.push(clause.replace("$p", `$p${params.length - 1}`));
};
pushSet("title = $p", data.title);
if (data.description !== undefined && data.description !== "") {
pushSet("description = $p", data.description);
}
pushSet("start_time = type::datetime($p)", data.start_time);
pushSet("end_time = type::datetime($p)", data.end_time);
pushSet("timezone = $p", data.timezone ?? "UTC");
if (data.location !== undefined) {
pushSet("location = $p", data.location);
}
sets.push(`creator = ${creator}`);
sets.push("attendee_count = { going: 0, maybe: 0, invited: 0 }");
pushSet("status = $p", data.status ?? 1);
sets.push("created_at = time::now()");
sets.push("updated_at = time::now()");
const sql = `CREATE event SET ${sets.join(", ")}`;
const rows = await this.db.query<Event>(sql, params);
const created = rows[0];
if (created === undefined || created === null) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.EVENT_CREATE_FAILED,
"Failed to create event — adapter returned no row"
);
}
return created;
}
async update(id: string, data: UpdateEvent): Promise<Event> {
const eventId = this.normalizeEventId(id);
const params: unknown[] = [];
const sets: string[] = [];
const pushSet = (clause: string, value: unknown): void => {
params.push(value);
sets.push(clause.replace("$p", `$p${params.length - 1}`));
};
if (data.title !== undefined) pushSet("title = $p", data.title);
if (data.description !== undefined)
pushSet("description = $p", data.description);
if (data.start_time !== undefined)
pushSet("start_time = type::datetime($p)", data.start_time);
if (data.end_time !== undefined)
pushSet("end_time = type::datetime($p)", data.end_time);
if (data.timezone !== undefined)
pushSet("timezone = $p", data.timezone);
if (data.location !== undefined)
pushSet("location = $p", data.location);
if (data.status !== undefined) pushSet("status = $p", data.status);
sets.push("updated_at = time::now()");
const sql = `UPDATE ${eventId} SET ${sets.join(", ")}`;
const rows = await this.db.query<Event>(sql, params);
const updated = rows[0];
if (updated === undefined || updated === null) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.EVENT_UPDATE_FAILED,
"Failed to update event — adapter returned no row"
);
}
return updated;
}
async delete(id: string): Promise<void> {
const eventId = this.normalizeEventId(id);
await this.db.query(`DELETE ${eventId}`);
}
async isCreator(eventId: string, userId: string): Promise<boolean> {
const normalizedEvent = this.normalizeEventId(eventId);
const normalizedUser = this.normalizeUserId(userId);
const rows = await this.db.query<{ creator: unknown }>(
`SELECT creator FROM ${normalizedEvent}`
);
const first = rows[0];
if (first === undefined || first === null) return false;
const creator = this.extractCreatorId(first.creator);
return this.normalizeUserId(creator) === normalizedUser;
}
async updateAttendeeCount(
eventId: string,
status: "going" | "maybe" | "invited" | "not_going",
increment: number
): Promise<void> {
if (status === "not_going") return;
const normalized = this.normalizeEventId(eventId);
// `increment` is interpolated rather than parameterised because
// SurrealDB does not allow arithmetic operators to take a bound
// var on the right of `field + X` — the value must appear
// literally. Caller passes a trusted integer.
const safe = Number.isFinite(increment) ? Math.trunc(increment) : 0;
await this.db.query(
`UPDATE ${normalized} SET attendee_count.${status} = attendee_count.${status} + ${safe}`
);
}
}

View File

@@ -0,0 +1,49 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type {
CreateEvent,
Event,
EventQuery,
UpdateEvent,
} from "../../schemas/event-schemas.js";
/** Constructor options for {@link IEventRepository}. */
export interface EventRepositoryConfig {
/**
* The SurrealDB adapter pulled from the host's `db`
* `ServiceRegistry` slot. The plugin only relies on the canonical
* `DatabaseAdapter.query(sql, params)` surface — no `getRawClient`
* cast, no engine-specific extensions.
*/
db: SurrealAdapter;
}
/**
* Public contract for event-entity CRUD. Implementations target a
* SurrealDB-shaped backend; the `SurrealAdapter` type is part of the
* public surface so consumers can substitute a custom adapter that
* conforms to the same interface (e.g. an in-memory test double).
*/
export interface IEventRepository {
/** Find all events matching the (optional) query. */
findAll(query?: EventQuery): Promise<Event[]>;
/** Find an event by id (`event:abc` or bare `abc`). */
findById(id: string): Promise<Event | null>;
/** Create a new event owned by `userId`. */
create(userId: string, data: CreateEvent): Promise<Event>;
/** Update an existing event by id. */
update(id: string, data: UpdateEvent): Promise<Event>;
/** Delete an event by id. */
delete(id: string): Promise<void>;
/** Return `true` when `userId` is the event's creator. */
isCreator(eventId: string, userId: string): Promise<boolean>;
/**
* Bump (or decrement) the bucketed attendee count by `increment`.
* The `"not_going"` status is a no-op because it is not tracked
* in the running counters.
*/
updateAttendeeCount(
eventId: string,
status: "going" | "maybe" | "invited" | "not_going",
increment: number
): Promise<void>;
}

View File

@@ -0,0 +1,5 @@
export { EventRepository } from "./event-repository.js";
export type {
EventRepositoryConfig,
IEventRepository,
} from "./event-repository.types.js";

View File

@@ -0,0 +1,5 @@
export { RsvpRepository } from "./rsvp-repository.js";
export type {
IRsvpRepository,
RsvpRepositoryConfig,
} from "./rsvp-repository.types.js";

View File

@@ -0,0 +1,115 @@
# rsvp-repository
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
SurrealDB-backed implementation of the `IRsvpRepository` contract.
The repository owns RSVP / invitation persistence: per-user
`findByEventAndUser`, paginated `findAttendees`, atomic `upsert`,
`delete` (returns the prior status), idempotent `createInvitations`,
and `countByStatus`. The route layer consumes this surface plus
`EventRepository.updateAttendeeCount` to keep the bucketed counters
on the parent event row in sync with the RSVP rows.
Like `EventRepository`, every SurrealQL statement uses positional
`$p0`-style placeholders to ride over the `DatabaseAdapter`
surface. The `(event, user)` uniqueness constraint is enforced at
the database layer via the `event_rsvp_unique_idx` UNIQUE index
declared in `surql/tables/event.surql`; the repository's `upsert`
performs an explicit `findByEventAndUser` first to discriminate
between CREATE and UPDATE paths so the route layer can compute the
old-status delta for attendee-count bookkeeping.
## Features
- 6-method surface mirroring the upstream parity.
- Idempotent ID normalisation — bare and prefixed ids both work.
- `createInvitations` skips users that already have an RSVP row so
resending an invite list never produces duplicates.
- Dedicated error codes — `RSVP_CREATE_FAILED` /
`RSVP_UPDATE_FAILED` for adapter-returns-empty cases.
- `countByStatus` falls back through `total` → `count` → `0` to
survive SurrealDB v2-vs-v3 result shape differences.
## Usage: Basic Example
```ts
import { RsvpRepository } from "@suaveplan/api-plugin-events";
const repo = new RsvpRepository({ db });
const rsvp = await repo.upsert("event:1", "user:1", { status: "going" });
console.log(rsvp.status); // "going"
```
## Usage: Advanced Example
```ts
import { RsvpRepository } from "@suaveplan/api-plugin-events";
const repo = new RsvpRepository({ db });
// Build a paginated attendee dashboard
const attendees = await repo.findAttendees("event:1", {
status: "going",
limit: 100,
offset: 0,
});
// Send a bulk invite — already-RSVPed users are skipped
const invitations = await repo.createInvitations("event:1", [
"user:2",
"user:3",
"user:4",
]);
console.log(invitations.length); // ≤ 3, depending on existing rows
// Cancel and capture the old status for an attendee-count rebalance
const oldStatus = await repo.delete("event:1", "user:2");
if (oldStatus !== null) {
// forward to EventRepository.updateAttendeeCount(..., -1)
}
// Counters
const going = await repo.countByStatus("event:1", "going");
```
## API Reference
### `class RsvpRepository implements IRsvpRepository`
Constructor: `new RsvpRepository({ db: SurrealAdapter })`.
- `findByEventAndUser(eventId, userId): Promise<Rsvp | null>`.
- `findAttendees(eventId, query?: AttendeeQuery): Promise<Attendee[]>`.
- `upsert(eventId, userId, data: CreateRsvp): Promise<Rsvp>` —
throws `RSVP_CREATE_FAILED` or `RSVP_UPDATE_FAILED` on empty
adapter response.
- `delete(eventId, userId): Promise<RsvpStatus | null>` — returns
the prior status, `null` when no row existed.
- `createInvitations(eventId, userIds: string[]): Promise<Rsvp[]>` —
returns only newly-created rows.
- `countByStatus(eventId, status: RsvpStatus): Promise<number>`.
### `interface IRsvpRepository`
Public contract — see `rsvp-repository.types.ts`.
### `interface RsvpRepositoryConfig`
`{ db: SurrealAdapter }`.
## Implementation Notes
`createInvitations` iterates the `userIds` list serially rather than
batching into a single SurrealQL statement because the
"already-invited?" check needs to be observable per-user, and
SurrealDB's `RELATION` / `INSERT INTO ... ON CONFLICT` is still
maturing in v3. The cost is N+1 queries for an invite of N users —
acceptable inside a route handler that's already guarded by
`maxInvitesPerRequest`. The `countByStatus` query uses `GROUP ALL`
to coerce SurrealDB v3's aggregation into a single-row result; v2's
implicit aggregation produced a flat row, and the dual `total ??
count ??` fallback survives both.

View File

@@ -0,0 +1,228 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EventsPluginError } from "../../error-codes/error-codes.js";
import { RsvpRepository } from "./rsvp-repository.js";
interface QueryCall {
sql: string;
params: readonly unknown[] | undefined;
}
function makeFakeDb(rows: unknown[][]): {
db: SurrealAdapter;
calls: QueryCall[];
} {
const calls: QueryCall[] = [];
let i = 0;
const db = {
async query<T>(sql: string, params?: readonly unknown[]) {
calls.push({ sql, params });
const row = rows[i++] ?? [];
return row as T[];
},
} as unknown as SurrealAdapter;
return { db, calls };
}
describe("RsvpRepository.findByEventAndUser", () => {
it("returns row when present", { timeout: 5000 }, async () => {
const row = { id: "event_rsvp:1", status: "going" };
const { db, calls } = makeFakeDb([[row]]);
const result = await new RsvpRepository({ db }).findByEventAndUser(
"1",
"1"
);
expect(result).toEqual(row);
expect(calls[0]!.sql).toContain("event = event:1");
expect(calls[0]!.sql).toContain("user = user:1");
});
it("returns null when absent", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[]]);
const result = await new RsvpRepository({ db }).findByEventAndUser(
"event:1",
"user:1"
);
expect(result).toBeNull();
});
});
describe("RsvpRepository.findAttendees", () => {
it("returns rows with default pagination", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[]]);
await new RsvpRepository({ db }).findAttendees("event:1");
expect(calls[0]!.sql).toContain("event = event:1");
expect(calls[0]!.sql).toContain("LIMIT $p0");
expect(calls[0]!.params).toEqual([50]);
});
it("applies status filter and offset", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([[]]);
await new RsvpRepository({ db }).findAttendees("event:1", {
status: "going",
limit: 10,
offset: 5,
});
expect(calls[0]!.sql).toContain("status = $p0");
expect(calls[0]!.sql).toContain("LIMIT $p1");
expect(calls[0]!.sql).toContain("START $p2");
expect(calls[0]!.params).toEqual(["going", 10, 5]);
});
});
describe("RsvpRepository.upsert", () => {
it("upsert creates new row when none exists", {
timeout: 5000,
}, async () => {
const { db, calls } = makeFakeDb([
[], // findByEventAndUser
[{ id: "event_rsvp:1", status: "going" }], // CREATE
]);
const r = await new RsvpRepository({ db }).upsert("1", "1", {
status: "going",
});
expect(r.status).toBe("going");
expect(calls[1]!.sql).toContain("CREATE event_rsvp SET");
expect(calls[1]!.params).toEqual(["going"]);
});
it("upsert updates when row exists", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([
[{ id: "event_rsvp:1", status: "going" }],
[{ id: "event_rsvp:1", status: "maybe" }],
]);
const r = await new RsvpRepository({ db }).upsert("event:1", "user:1", {
status: "maybe",
});
expect(r.status).toBe("maybe");
expect(calls[1]!.sql).toContain("UPDATE event_rsvp");
});
it("upsert raises RSVP_CREATE_FAILED on empty create response", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([
[], // findByEventAndUser
[], // CREATE returns empty
]);
await expect(
new RsvpRepository({ db }).upsert("1", "1", { status: "going" })
).rejects.toBeInstanceOf(EventsPluginError);
});
it("upsert raises RSVP_UPDATE_FAILED on empty update response", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([
[{ id: "event_rsvp:1", status: "going" }],
[],
]);
await expect(
new RsvpRepository({ db }).upsert("1", "1", { status: "maybe" })
).rejects.toBeInstanceOf(EventsPluginError);
});
});
describe("RsvpRepository.delete", () => {
it("delete returns prior status", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([
[{ id: "event_rsvp:1", status: "going" }],
[],
]);
const result = await new RsvpRepository({ db }).delete("1", "1");
expect(result).toBe("going");
expect(calls[1]!.sql).toContain("DELETE event_rsvp");
});
it("delete returns null when no prior row", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[]]);
const result = await new RsvpRepository({ db }).delete("1", "1");
expect(result).toBeNull();
});
});
describe("RsvpRepository.createInvitations", () => {
it("createInvitations creates fresh rows", { timeout: 5000 }, async () => {
const { db, calls } = makeFakeDb([
[], // findByEventAndUser for user:1
[{ id: "event_rsvp:1" }], // CREATE for user:1
[], // findByEventAndUser for user:2
[{ id: "event_rsvp:2" }], // CREATE for user:2
]);
const result = await new RsvpRepository({ db }).createInvitations("1", [
"1",
"2",
]);
expect(result).toHaveLength(2);
expect(calls).toHaveLength(4);
});
it("createInvitations skips existing rows", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([
[{ id: "event_rsvp:1", status: "going" }], // user:1 already RSVPed
[], // findByEventAndUser for user:2
[{ id: "event_rsvp:2" }],
]);
const result = await new RsvpRepository({ db }).createInvitations("1", [
"1",
"2",
]);
expect(result).toHaveLength(1);
});
it("createInvitations tolerates empty create responses", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([
[],
[], // CREATE returns empty
]);
const result = await new RsvpRepository({ db }).createInvitations("1", [
"1",
]);
expect(result).toHaveLength(0);
});
});
describe("RsvpRepository.countByStatus", () => {
it("countByStatus returns 0 on empty result", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[]]);
expect(
await new RsvpRepository({ db }).countByStatus("1", "going")
).toBe(0);
});
it("countByStatus returns the total", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[{ total: 7 }]]);
expect(
await new RsvpRepository({ db }).countByStatus("1", "going")
).toBe(7);
});
it("countByStatus falls back to count alias", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[{ count: 3 }]]);
expect(
await new RsvpRepository({ db }).countByStatus("1", "going")
).toBe(3);
});
it("countByStatus returns 0 when row null", { timeout: 5000 }, async () => {
const { db } = makeFakeDb([[null]]);
expect(
await new RsvpRepository({ db }).countByStatus("1", "going")
).toBe(0);
});
it("countByStatus returns 0 when neither total nor count present", {
timeout: 5000,
}, async () => {
const { db } = makeFakeDb([[{ other: 1 }]]);
expect(
await new RsvpRepository({ db }).countByStatus("1", "going")
).toBe(0);
});
});

View File

@@ -0,0 +1,160 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
} from "../../error-codes/error-codes.js";
import type {
Attendee,
AttendeeQuery,
CreateRsvp,
Rsvp,
RsvpStatus,
} from "../../schemas/rsvp-schemas.js";
import type {
IRsvpRepository,
RsvpRepositoryConfig,
} from "./rsvp-repository.types.js";
/**
* SurrealDB-backed implementation of {@link IRsvpRepository}.
*
* Like {@link import("../event-repository/event-repository.js").EventRepository},
* positional `$pN` placeholders are used throughout because the
* genesis `DatabaseAdapter.query` surface only forwards positional
* params.
*/
export class RsvpRepository implements IRsvpRepository {
private db: SurrealAdapter;
constructor(config: RsvpRepositoryConfig) {
this.db = config.db;
}
private normalizeEventId(id: string): string {
return id.includes(":") ? id : `event:${id}`;
}
private normalizeUserId(id: string): string {
return id.includes(":") ? id : `user:${id}`;
}
async findByEventAndUser(
eventId: string,
userId: string
): Promise<Rsvp | null> {
const event = this.normalizeEventId(eventId);
const user = this.normalizeUserId(userId);
const rows = await this.db.query<Rsvp>(
`SELECT * FROM event_rsvp WHERE event = ${event} AND user = ${user}`
);
return rows[0] ?? null;
}
async findAttendees(
eventId: string,
query?: AttendeeQuery
): Promise<Attendee[]> {
const event = this.normalizeEventId(eventId);
const limit = query?.limit ?? 50;
const offset = query?.offset ?? 0;
const params: unknown[] = [];
let sql = `SELECT id, event, user, status, created_at, updated_at FROM event_rsvp WHERE event = ${event}`;
if (query?.status !== undefined) {
params.push(query.status);
sql += ` AND status = $p${params.length - 1}`;
}
sql += " ORDER BY created_at DESC";
params.push(limit);
sql += ` LIMIT $p${params.length - 1}`;
if (offset > 0) {
params.push(offset);
sql += ` START $p${params.length - 1}`;
}
const rows = await this.db.query<Attendee>(sql, params);
return rows;
}
async upsert(
eventId: string,
userId: string,
data: CreateRsvp
): Promise<Rsvp> {
const event = this.normalizeEventId(eventId);
const user = this.normalizeUserId(userId);
const existing = await this.findByEventAndUser(eventId, userId);
if (existing !== null) {
const rows = await this.db.query<Rsvp>(
`UPDATE event_rsvp SET status = $p0, updated_at = time::now() WHERE event = ${event} AND user = ${user}`,
[data.status]
);
const updated = rows[0];
if (updated === undefined || updated === null) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.RSVP_UPDATE_FAILED,
"Failed to update RSVP — adapter returned no row"
);
}
return updated;
}
const rows = await this.db.query<Rsvp>(
`CREATE event_rsvp SET event = ${event}, user = ${user}, status = $p0, created_at = time::now(), updated_at = time::now()`,
[data.status]
);
const created = rows[0];
if (created === undefined || created === null) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.RSVP_CREATE_FAILED,
"Failed to create RSVP — adapter returned no row"
);
}
return created;
}
async delete(eventId: string, userId: string): Promise<RsvpStatus | null> {
const existing = await this.findByEventAndUser(eventId, userId);
if (existing === null) return null;
const event = this.normalizeEventId(eventId);
const user = this.normalizeUserId(userId);
await this.db.query(
`DELETE event_rsvp WHERE event = ${event} AND user = ${user}`
);
return existing.status;
}
async createInvitations(
eventId: string,
userIds: string[]
): Promise<Rsvp[]> {
const event = this.normalizeEventId(eventId);
const invitations: Rsvp[] = [];
for (const userId of userIds) {
const existing = await this.findByEventAndUser(eventId, userId);
if (existing !== null) continue;
const user = this.normalizeUserId(userId);
const rows = await this.db.query<Rsvp>(
`CREATE event_rsvp SET event = ${event}, user = ${user}, status = 'invited', created_at = time::now(), updated_at = time::now()`
);
const created = rows[0];
if (created !== undefined && created !== null) {
invitations.push(created);
}
}
return invitations;
}
async countByStatus(eventId: string, status: RsvpStatus): Promise<number> {
const event = this.normalizeEventId(eventId);
const rows = await this.db.query<{ total: number; count?: number }>(
`SELECT count() AS total FROM event_rsvp WHERE event = ${event} AND status = $p0 GROUP ALL`,
[status]
);
const first = rows[0];
if (first === undefined || first === null) return 0;
return first.total ?? first.count ?? 0;
}
}

View File

@@ -0,0 +1,37 @@
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import type {
Attendee,
AttendeeQuery,
CreateRsvp,
Rsvp,
RsvpStatus,
} from "../../schemas/rsvp-schemas.js";
/** Constructor options for {@link IRsvpRepository}. */
export interface RsvpRepositoryConfig {
db: SurrealAdapter;
}
/** Public contract for RSVP / invitation persistence. */
export interface IRsvpRepository {
/** Look up the RSVP row for a single (event, user) pair. */
findByEventAndUser(eventId: string, userId: string): Promise<Rsvp | null>;
/** List the attendees of an event with pagination + status filter. */
findAttendees(eventId: string, query?: AttendeeQuery): Promise<Attendee[]>;
/** Create-or-update the RSVP for a given (event, user) pair. */
upsert(eventId: string, userId: string, data: CreateRsvp): Promise<Rsvp>;
/**
* Delete an RSVP row. Returns the prior status when a row existed,
* `null` when no row was found (the route layer turns the `null`
* into a 404).
*/
delete(eventId: string, userId: string): Promise<RsvpStatus | null>;
/**
* Idempotently create `invited` rows for every user that does not
* already have an RSVP on the event. Returns only the newly
* created rows.
*/
createInvitations(eventId: string, userIds: string[]): Promise<Rsvp[]>;
/** Count RSVPs by status — used by attendee dashboards. */
countByStatus(eventId: string, status: RsvpStatus): Promise<number>;
}

View File

@@ -0,0 +1,118 @@
# events-routes
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds the nine-route OpenAPI Hono module mounted by
`createEventsPlugin`. Each route is wrapped by `@hono/zod-openapi`'s
`createRoute` so the package's local Zod schemas drive both
input-validation and generated OpenAPI doc shape. The route layer is
the single point at which auth (`c.get("user")`), authorisation
(`eventRepository.isCreator`), `maxInvitesPerRequest` enforcement,
OTel emission, and EventBus fan-out converge.
The handlers themselves are deliberately thin: every meaningful
side-effect (CREATE row, UPDATE counter, DELETE row, EMIT event)
funnels through the two repositories and the optional `emitEvent`
callback. The callback is the integration point the plugin's
`onReady` uses to forward route events onto the shared
`ctx.eventBus` — that indirection is what fixes the upstream
"local emitter" smell flagged in `enterprise/api-plugin-events.md`.
## Features
- 9 routes: list / get / create / update / delete / rsvp /
cancel-rsvp / my-rsvp / attendees / invite.
- Zod validation on every body / param / query.
- Auth guard on every route via `c.get("user")`.
- Authorisation guard on every mutating route via
`eventRepository.isCreator`.
- `maxInvitesPerRequest` enforcement inside the `/invite` handler.
- OTel counter emission on every successful mutation.
- Typed `emitEvent` callback for cross-plugin fan-out.
## Usage: Basic Example
```ts
import { createEventRoutes } from "@suaveplan/api-plugin-events";
import { OpenAPIHono } from "@hono/zod-openapi";
const routes = createEventRoutes({
eventRepository,
rsvpRepository,
maxInvitesPerRequest: 50,
});
const parent = new OpenAPIHono();
parent.use(async (c, next) => {
c.set("user", await resolveUser(c));
await next();
});
parent.route("/events", routes);
```
## Usage: Advanced Example
```ts
import { createEventRoutes } from "@suaveplan/api-plugin-events";
const routes = createEventRoutes({
eventRepository,
rsvpRepository,
maxInvitesPerRequest: 25,
emitEvent: (event) => {
// Forward onto the host's typed bus
host.eventBus.emit(event.type, event.payload);
// Side-channel: persistent audit log
auditLog.write({
kind: event.type,
payload: event.payload,
at: new Date(),
});
},
});
// `routes` is an OpenAPIHono instance — feed it into the OpenAPI
// document generator:
host.openAPIRegistry.attach(routes);
```
## API Reference
### `createEventRoutes(config: EventRoutesConfig): OpenAPIHono`
Returns an `OpenAPIHono<{ Variables: EventsRoutesVariables }>` with
the nine routes registered.
### `interface EventRoutesConfig`
- `eventRepository: IEventRepository`
- `rsvpRepository: IRsvpRepository`
- `maxInvitesPerRequest: number`
- `emitEvent?: (event: EventsRouteEvent) => void`
### `interface EventsRoutesVariables`
`{ user: { id: string } & Record<string, unknown> | null | undefined }`
— the route handler reads `c.get("user")` from this slot; the host
supplies the auth middleware that populates it.
## Implementation Notes
The handlers manually `try { getUser(c) } catch { return c.json(...) }`
rather than relying on Hono's `onError` because the OpenAPI route
spec requires every documented response status to be reachable from
the handler body. Catching `EventsPluginError(UNAUTHENTICATED)` and
returning a 401 inline keeps the spec honest. The pattern repeats
on every route — verbose, but each block is a single
3-line `try`/`catch`/`json` triple.
The `app.route("/", routes)` line in test fixtures and in the plugin
mount mirrors the parent/child Hono pattern: the parent app owns the
auth middleware, the child app owns the routes. Hono middleware
registered on the parent BEFORE the `.route()` call fires before the
child's handlers, so `c.get("user")` resolves correctly during the
child's `c.req.valid(...)` parsing phase.

View File

@@ -0,0 +1,557 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { resetEventsInstruments } from "../../otel/instruments.js";
import type { IEventRepository } from "../../repositories/event-repository/event-repository.types.js";
import type { IRsvpRepository } from "../../repositories/rsvp-repository/rsvp-repository.types.js";
import type {
CreateEvent,
Event,
UpdateEvent,
} from "../../schemas/event-schemas.js";
import type { CreateRsvp, Rsvp } from "../../schemas/rsvp-schemas.js";
import type { EventsRouteEvent } from "../../types/types.js";
import { createEventRoutes } from "./events-routes.js";
const baseEvent: Event = {
id: "event:1",
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const baseRsvp: Rsvp = {
id: "event_rsvp:1",
event: "event:1",
user: "user:1",
status: "going",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
interface RepoOverrides {
findAll?: (q?: unknown) => Promise<Event[]>;
findById?: (id: string) => Promise<Event | null>;
create?: (uid: string, d: CreateEvent) => Promise<Event>;
update?: (id: string, d: UpdateEvent) => Promise<Event>;
deleteFn?: (id: string) => Promise<void>;
isCreator?: (e: string, u: string) => Promise<boolean>;
updateCount?: (...args: unknown[]) => Promise<void>;
}
function makeEventRepo(overrides: RepoOverrides = {}): IEventRepository {
return {
findAll: overrides.findAll ?? (async () => []),
findById: overrides.findById ?? (async () => baseEvent),
create: overrides.create ?? (async () => baseEvent),
update: overrides.update ?? (async () => baseEvent),
delete: overrides.deleteFn ?? (async () => undefined),
isCreator: overrides.isCreator ?? (async () => true),
updateAttendeeCount: overrides.updateCount ?? (async () => undefined),
} as IEventRepository;
}
interface RsvpOverrides {
findByEventAndUser?: (e: string, u: string) => Promise<Rsvp | null>;
findAttendees?: () => Promise<Rsvp[]>;
upsert?: (e: string, u: string, d: CreateRsvp) => Promise<Rsvp>;
deleteFn?: (
e: string,
u: string
) => Promise<"going" | "maybe" | "invited" | "not_going" | null>;
createInvitations?: (e: string, ids: string[]) => Promise<Rsvp[]>;
countByStatus?: () => Promise<number>;
}
function makeRsvpRepo(overrides: RsvpOverrides = {}): IRsvpRepository {
return {
findByEventAndUser: overrides.findByEventAndUser ?? (async () => null),
findAttendees: overrides.findAttendees ?? (async () => []),
upsert: overrides.upsert ?? (async () => baseRsvp),
delete: overrides.deleteFn ?? (async () => null),
createInvitations: overrides.createInvitations ?? (async () => []),
countByStatus: overrides.countByStatus ?? (async () => 0),
} as IRsvpRepository;
}
interface BuildOpts {
user?: { id: string } | null;
eventRepo?: IEventRepository;
rsvpRepo?: IRsvpRepository;
maxInvitesPerRequest?: number;
emitEvent?: (e: EventsRouteEvent) => void;
}
function buildApp(opts: BuildOpts = {}) {
const config = {
eventRepository: opts.eventRepo ?? makeEventRepo(),
rsvpRepository: opts.rsvpRepo ?? makeRsvpRepo(),
maxInvitesPerRequest: opts.maxInvitesPerRequest ?? 50,
};
const routes =
opts.emitEvent === undefined
? createEventRoutes(config)
: createEventRoutes({ ...config, emitEvent: opts.emitEvent });
const user = opts.user === undefined ? { id: "user:1" } : opts.user;
// Parent app injects the user variable BEFORE the routes module is
// mounted; the host (api-plugin-database) supplies auth middleware
// in production, the test mirrors that ordering here.
const parent = new OpenAPIHono<{
Variables: { user: { id: string } | null | undefined };
}>();
parent.use(async (c, next) => {
c.set("user", user);
await next();
});
parent.route("/", routes);
return parent;
}
afterEach(() => {
resetEventsInstruments();
});
describe("createEventRoutes", () => {
it("GET / returns 401 without user", { timeout: 5000 }, async () => {
const app = buildApp({ user: null });
const res = await app.request("/");
expect(res.status).toBe(401);
const body = (await res.json()) as { error: string };
expect(body.error).toBe("Authentication required");
});
it("GET / returns paginated events", { timeout: 5000 }, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ findAll: async () => [baseEvent] }),
});
const res = await app.request("/");
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Event[] };
expect(body.data).toHaveLength(1);
});
it("GET /{id} returns 404 when missing", { timeout: 5000 }, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ findById: async () => null }),
});
const res = await app.request("/missing");
expect(res.status).toBe(404);
});
it("GET /{id} returns event when present", { timeout: 5000 }, async () => {
const app = buildApp();
const res = await app.request("/event:1");
expect(res.status).toBe(200);
const body = (await res.json()) as Event;
expect(body.id).toBe("event:1");
});
it("GET /{id} returns 401 without user", { timeout: 5000 }, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1");
expect(res.status).toBe(401);
});
it("POST / emits event:created and returns 201", {
timeout: 5000,
}, async () => {
const events: EventsRouteEvent[] = [];
const app = buildApp({ emitEvent: (e) => events.push(e) });
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
}),
});
expect(res.status).toBe(201);
expect(events[0]?.type).toBe("event:created");
});
it("POST / returns 401 without user", { timeout: 5000 }, async () => {
const app = buildApp({ user: null });
const res = await app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
}),
});
expect(res.status).toBe(401);
});
it("PUT /{id} returns 403 for non-creator", { timeout: 5000 }, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ isCreator: async () => false }),
});
const res = await app.request("/event:1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "X" }),
});
expect(res.status).toBe(403);
});
it("PUT /{id} returns 404 when event missing", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ findById: async () => null }),
});
const res = await app.request("/event:1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "X" }),
});
expect(res.status).toBe(404);
});
it("PUT /{id} updates and emits event:updated", {
timeout: 5000,
}, async () => {
const events: EventsRouteEvent[] = [];
const app = buildApp({ emitEvent: (e) => events.push(e) });
const res = await app.request("/event:1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "Updated" }),
});
expect(res.status).toBe(200);
expect(events[0]?.type).toBe("event:updated");
});
it("PUT /{id} returns 401 without user", { timeout: 5000 }, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ title: "X" }),
});
expect(res.status).toBe(401);
});
it("DELETE /{id} returns 403 for non-creator", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ isCreator: async () => false }),
});
const res = await app.request("/event:1", { method: "DELETE" });
expect(res.status).toBe(403);
});
it("DELETE /{id} returns 404 when event missing", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ findById: async () => null }),
});
const res = await app.request("/event:1", { method: "DELETE" });
expect(res.status).toBe(404);
});
it("DELETE /{id} succeeds and emits event:cancelled", {
timeout: 5000,
}, async () => {
const events: EventsRouteEvent[] = [];
const app = buildApp({ emitEvent: (e) => events.push(e) });
const res = await app.request("/event:1", { method: "DELETE" });
expect(res.status).toBe(200);
expect(events[0]?.type).toBe("event:cancelled");
});
it("DELETE /{id} returns 401 without user", { timeout: 5000 }, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1", { method: "DELETE" });
expect(res.status).toBe(401);
});
});
describe("createEventRoutes — RSVP", () => {
it("POST /{id}/rsvp updates attendee count", {
timeout: 5000,
}, async () => {
const counts: Array<[string, string, number]> = [];
const events: EventsRouteEvent[] = [];
const app = buildApp({
eventRepo: makeEventRepo({
updateCount: async (...args: unknown[]): Promise<void> => {
counts.push([
args[0] as string,
args[1] as string,
args[2] as number,
]);
},
}),
emitEvent: (e) => events.push(e),
});
const res = await app.request("/event:1/rsvp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: "going" }),
});
expect(res.status).toBe(201);
expect(counts).toContainEqual(["event:1", "going", 1]);
expect(events[0]?.type).toBe("rsvp:created");
});
it("POST /{id}/rsvp decrements old status when changing", {
timeout: 5000,
}, async () => {
const counts: Array<[string, string, number]> = [];
const app = buildApp({
rsvpRepo: makeRsvpRepo({
findByEventAndUser: async () => ({
...baseRsvp,
status: "maybe",
}),
}),
eventRepo: makeEventRepo({
updateCount: async (...args: unknown[]): Promise<void> => {
counts.push([
args[0] as string,
args[1] as string,
args[2] as number,
]);
},
}),
});
const res = await app.request("/event:1/rsvp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: "going" }),
});
expect(res.status).toBe(201);
expect(counts).toContainEqual(["event:1", "maybe", -1]);
expect(counts).toContainEqual(["event:1", "going", 1]);
});
it("POST /{id}/rsvp with not_going skips count bump", {
timeout: 5000,
}, async () => {
const counts: Array<[string, string, number]> = [];
const app = buildApp({
eventRepo: makeEventRepo({
updateCount: async (...args: unknown[]): Promise<void> => {
counts.push([
args[0] as string,
args[1] as string,
args[2] as number,
]);
},
}),
});
const res = await app.request("/event:1/rsvp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: "not_going" }),
});
expect(res.status).toBe(201);
expect(counts.find((c) => c[1] === "going")).toBeUndefined();
});
it("POST /{id}/rsvp returns 404 when event missing", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ findById: async () => null }),
});
const res = await app.request("/event:1/rsvp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: "going" }),
});
expect(res.status).toBe(404);
});
it("POST /{id}/rsvp returns 401 without user", {
timeout: 5000,
}, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1/rsvp", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ status: "going" }),
});
expect(res.status).toBe(401);
});
it("DELETE /{id}/rsvp returns 404 when none", {
timeout: 5000,
}, async () => {
const app = buildApp({
rsvpRepo: makeRsvpRepo({ deleteFn: async () => null }),
});
const res = await app.request("/event:1/rsvp", { method: "DELETE" });
expect(res.status).toBe(404);
});
it("DELETE /{id}/rsvp succeeds and emits rsvp:cancelled", {
timeout: 5000,
}, async () => {
const events: EventsRouteEvent[] = [];
const app = buildApp({
rsvpRepo: makeRsvpRepo({ deleteFn: async () => "going" }),
emitEvent: (e) => events.push(e),
});
const res = await app.request("/event:1/rsvp", { method: "DELETE" });
expect(res.status).toBe(200);
expect(events[0]?.type).toBe("rsvp:cancelled");
});
it("DELETE /{id}/rsvp with prior not_going skips count bump", {
timeout: 5000,
}, async () => {
const counts: Array<[string, string, number]> = [];
const app = buildApp({
rsvpRepo: makeRsvpRepo({ deleteFn: async () => "not_going" }),
eventRepo: makeEventRepo({
updateCount: async (...args: unknown[]): Promise<void> => {
counts.push([
args[0] as string,
args[1] as string,
args[2] as number,
]);
},
}),
});
const res = await app.request("/event:1/rsvp", { method: "DELETE" });
expect(res.status).toBe(200);
expect(counts).toHaveLength(0);
});
it("DELETE /{id}/rsvp returns 401 without user", {
timeout: 5000,
}, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1/rsvp", { method: "DELETE" });
expect(res.status).toBe(401);
});
it("GET /{id}/my-rsvp returns null when none", {
timeout: 5000,
}, async () => {
const app = buildApp();
const res = await app.request("/event:1/my-rsvp");
expect(res.status).toBe(200);
const body = (await res.json()) as { rsvp: Rsvp | null };
expect(body.rsvp).toBeNull();
});
it("GET /{id}/my-rsvp returns 401 without user", {
timeout: 5000,
}, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1/my-rsvp");
expect(res.status).toBe(401);
});
it("GET /{id}/attendees returns list", { timeout: 5000 }, async () => {
const app = buildApp({
rsvpRepo: makeRsvpRepo({ findAttendees: async () => [baseRsvp] }),
});
const res = await app.request("/event:1/attendees");
expect(res.status).toBe(200);
const body = (await res.json()) as { data: Rsvp[]; total: number };
expect(body.total).toBe(1);
});
it("GET /{id}/attendees returns 401 without user", {
timeout: 5000,
}, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1/attendees");
expect(res.status).toBe(401);
});
});
describe("createEventRoutes — invite", () => {
it("POST /{id}/invite returns 403 for non-creator", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ isCreator: async () => false }),
});
const res = await app.request("/event:1/invite", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ user_ids: ["user:2"] }),
});
expect(res.status).toBe(403);
});
it("POST /{id}/invite returns 404 when event missing", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ findById: async () => null }),
});
const res = await app.request("/event:1/invite", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ user_ids: ["user:2"] }),
});
expect(res.status).toBe(404);
});
it("POST /{id}/invite succeeds and emits event:invitations:sent", {
timeout: 5000,
}, async () => {
const events: EventsRouteEvent[] = [];
const app = buildApp({
rsvpRepo: makeRsvpRepo({
createInvitations: async () => [
baseRsvp,
{ ...baseRsvp, id: "event_rsvp:2" },
],
}),
emitEvent: (e) => events.push(e),
});
const res = await app.request("/event:1/invite", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ user_ids: ["user:2", "user:3"] }),
});
expect(res.status).toBe(201);
const body = (await res.json()) as { total: number };
expect(body.total).toBe(2);
expect(
events.find((e) => e.type === "event:invitations:sent")
).toBeDefined();
});
it("POST /{id}/invite returns 400 when user_ids exceeds maxInvitesPerRequest", {
timeout: 5000,
}, async () => {
const app = buildApp({ maxInvitesPerRequest: 2 });
const res = await app.request("/event:1/invite", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
user_ids: ["user:1", "user:2", "user:3"],
}),
});
expect(res.status).toBe(400);
});
it("POST /{id}/invite returns 401 without user", {
timeout: 5000,
}, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1/invite", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ user_ids: ["user:2"] }),
});
expect(res.status).toBe(401);
});
});

View File

@@ -0,0 +1,746 @@
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import {
EVENTS_PLUGIN_CODES,
EventsPluginError,
} from "../../error-codes/error-codes.js";
import { getEventsInstruments } from "../../otel/instruments.js";
import {
CreateEventSchema,
EventIdParamSchema,
EventListResponseSchema,
EventQuerySchema,
EventSchema,
UpdateEventSchema,
} from "../../schemas/event-schemas.js";
import {
AttendeeListResponseSchema,
AttendeeQuerySchema,
CreateRsvpSchema,
InviteResponseSchema,
InviteUsersSchema,
RsvpResponseSchema,
RsvpSchema,
} from "../../schemas/rsvp-schemas.js";
import type { EventRoutesConfig } from "../../types/types.js";
/** Public auth surface — populated by the host's auth middleware. */
type AuthedUser = { id: string } & Record<string, unknown>;
/** Hono `Variables` for this route bundle. */
export interface EventsRoutesVariables {
user: AuthedUser | null | undefined;
}
const ErrorBodySchema = z.object({ error: z.string(), code: z.string() });
function errorBody(
code: string,
error: string
): { error: string; code: string } {
return { error, code };
}
function getUser(c: {
get: (key: "user") => AuthedUser | null | undefined;
}): AuthedUser {
const user = c.get("user");
if (user === null || user === undefined) {
throw new EventsPluginError(
EVENTS_PLUGIN_CODES.UNAUTHENTICATED,
"Authentication required"
);
}
return user;
}
/**
* Build the events plugin's nine-route Hono module.
*
* The host mounts the returned `OpenAPIHono` instance under a
* prefix (default `/events`) — see `createEventsPlugin` `onReady`.
*
* Authentication is required on every route: the handler reads
* `c.get("user")` set by the host's auth middleware. Missing user →
* `401` with body `{ error: "Authentication required" }`. Only the
* event creator may PUT / DELETE / invite. Domain events are forwarded
* to the host's `eventBus` via the injected `emitEvent` callback so
* sibling plugins can subscribe; the route handler never references
* the bus directly.
*/
export function createEventRoutes(
config: EventRoutesConfig
): OpenAPIHono<{ Variables: EventsRoutesVariables }> {
const app = new OpenAPIHono<{ Variables: EventsRoutesVariables }>();
registerCrudRoutes(app, config);
registerRsvpRoutes(app, config);
registerInviteRoute(app, config);
return app;
}
function registerCrudRoutes(
app: OpenAPIHono<{ Variables: EventsRoutesVariables }>,
config: EventRoutesConfig
): void {
const { eventRepository } = config;
app.openapi(
createRoute({
method: "get",
path: "/",
tags: ["events"],
summary: "List events",
request: { query: EventQuerySchema },
responses: {
200: {
description: "List",
content: {
"application/json": { schema: EventListResponseSchema },
},
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
try {
getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const query = c.req.valid("query");
const data = await eventRepository.findAll(query);
return c.json({ data }, 200);
}
);
app.openapi(
createRoute({
method: "get",
path: "/{id}",
tags: ["events"],
summary: "Get event",
request: { params: EventIdParamSchema },
responses: {
200: {
description: "Event",
content: { "application/json": { schema: EventSchema } },
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
try {
getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const { id } = c.req.valid("param");
const event = await eventRepository.findById(id);
if (event === null) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.EVENT_NOT_FOUND,
"Event not found"
),
404
);
}
return c.json(event, 200);
}
);
app.openapi(
createRoute({
method: "post",
path: "/",
tags: ["events"],
summary: "Create event",
request: {
body: {
content: {
"application/json": { schema: CreateEventSchema },
},
},
},
responses: {
201: {
description: "Created",
content: { "application/json": { schema: EventSchema } },
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
let user: AuthedUser;
try {
user = getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const body = c.req.valid("json");
const event = await eventRepository.create(user.id, body);
getEventsInstruments().eventsCreatedTotal.add(1);
config.emitEvent?.({
type: "event:created",
payload: { eventId: String(event.id), creatorId: user.id },
});
return c.json(event, 201);
}
);
app.openapi(
createRoute({
method: "put",
path: "/{id}",
tags: ["events"],
summary: "Update event",
request: {
params: EventIdParamSchema,
body: {
content: {
"application/json": { schema: UpdateEventSchema },
},
},
},
responses: {
200: {
description: "Updated",
content: { "application/json": { schema: EventSchema } },
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
403: {
description: "Forbidden",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
let user: AuthedUser;
try {
user = getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const { id } = c.req.valid("param");
const data = c.req.valid("json");
const existing = await eventRepository.findById(id);
if (existing === null) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.EVENT_NOT_FOUND,
"Event not found"
),
404
);
}
if (!(await eventRepository.isCreator(id, user.id))) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.FORBIDDEN,
"Only event creator can update this event"
),
403
);
}
const updated = await eventRepository.update(id, data);
getEventsInstruments().eventsUpdatedTotal.add(1);
config.emitEvent?.({
type: "event:updated",
payload: { eventId: id, updatedBy: user.id },
});
return c.json(updated, 200);
}
);
app.openapi(
createRoute({
method: "delete",
path: "/{id}",
tags: ["events"],
summary: "Delete event",
request: { params: EventIdParamSchema },
responses: {
200: {
description: "Deleted",
content: {
"application/json": {
schema: z.object({ ok: z.literal(true) }),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
403: {
description: "Forbidden",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
let user: AuthedUser;
try {
user = getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const { id } = c.req.valid("param");
const existing = await eventRepository.findById(id);
if (existing === null) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.EVENT_NOT_FOUND,
"Event not found"
),
404
);
}
if (!(await eventRepository.isCreator(id, user.id))) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.FORBIDDEN,
"Only event creator can delete this event"
),
403
);
}
await eventRepository.delete(id);
getEventsInstruments().eventsDeletedTotal.add(1);
config.emitEvent?.({
type: "event:cancelled",
payload: { eventId: id, cancelledBy: user.id },
});
return c.json({ ok: true as const }, 200);
}
);
}
function registerRsvpRoutes(
app: OpenAPIHono<{ Variables: EventsRoutesVariables }>,
config: EventRoutesConfig
): void {
const { eventRepository, rsvpRepository } = config;
app.openapi(
createRoute({
method: "post",
path: "/{id}/rsvp",
tags: ["events", "rsvp"],
summary: "RSVP to event",
request: {
params: EventIdParamSchema,
body: {
content: {
"application/json": { schema: CreateRsvpSchema },
},
},
},
responses: {
201: {
description: "RSVP created/updated",
content: {
"application/json": {
schema: z.object({
rsvp: RsvpSchema,
oldStatus: z.string().nullable(),
}),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
let user: AuthedUser;
try {
user = getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const { id } = c.req.valid("param");
const { status } = c.req.valid("json");
if ((await eventRepository.findById(id)) === null) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.EVENT_NOT_FOUND,
"Event not found"
),
404
);
}
const existing = await rsvpRepository.findByEventAndUser(
id,
user.id
);
const oldStatus = existing?.status ?? null;
const rsvp = await rsvpRepository.upsert(id, user.id, { status });
if (oldStatus !== null && oldStatus !== "not_going") {
await eventRepository.updateAttendeeCount(id, oldStatus, -1);
}
if (status !== "not_going") {
await eventRepository.updateAttendeeCount(id, status, 1);
}
getEventsInstruments().rsvpsWrittenTotal.add(1, { status });
config.emitEvent?.({
type: "rsvp:created",
payload: { eventId: id, userId: user.id, status },
});
return c.json({ rsvp, oldStatus }, 201);
}
);
app.openapi(
createRoute({
method: "delete",
path: "/{id}/rsvp",
tags: ["events", "rsvp"],
summary: "Cancel RSVP",
request: { params: EventIdParamSchema },
responses: {
200: {
description: "Cancelled",
content: {
"application/json": {
schema: z.object({ ok: z.literal(true) }),
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
let user: AuthedUser;
try {
user = getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const { id } = c.req.valid("param");
const oldStatus = await rsvpRepository.delete(id, user.id);
if (oldStatus === null) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.RSVP_NOT_FOUND,
"RSVP not found"
),
404
);
}
if (oldStatus !== "not_going") {
await eventRepository.updateAttendeeCount(id, oldStatus, -1);
}
config.emitEvent?.({
type: "rsvp:cancelled",
payload: { eventId: id, userId: user.id, oldStatus },
});
return c.json({ ok: true as const }, 200);
}
);
app.openapi(
createRoute({
method: "get",
path: "/{id}/my-rsvp",
tags: ["events", "rsvp"],
summary: "Get my RSVP",
request: { params: EventIdParamSchema },
responses: {
200: {
description: "RSVP",
content: {
"application/json": { schema: RsvpResponseSchema },
},
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
let user: AuthedUser;
try {
user = getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const { id } = c.req.valid("param");
const rsvp = await rsvpRepository.findByEventAndUser(id, user.id);
return c.json({ rsvp }, 200);
}
);
app.openapi(
createRoute({
method: "get",
path: "/{id}/attendees",
tags: ["events", "rsvp"],
summary: "List attendees",
request: { params: EventIdParamSchema, query: AttendeeQuerySchema },
responses: {
200: {
description: "Attendees",
content: {
"application/json": {
schema: AttendeeListResponseSchema,
},
},
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
try {
getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const { id } = c.req.valid("param");
const query = c.req.valid("query");
const data = await rsvpRepository.findAttendees(id, query);
return c.json({ data, total: data.length }, 200);
}
);
}
function registerInviteRoute(
app: OpenAPIHono<{ Variables: EventsRoutesVariables }>,
config: EventRoutesConfig
): void {
const { eventRepository, rsvpRepository, maxInvitesPerRequest } = config;
app.openapi(
createRoute({
method: "post",
path: "/{id}/invite",
tags: ["events", "rsvp"],
summary: "Invite users",
request: {
params: EventIdParamSchema,
body: {
content: {
"application/json": { schema: InviteUsersSchema },
},
},
},
responses: {
201: {
description: "Invited",
content: {
"application/json": { schema: InviteResponseSchema },
},
},
400: {
description: "Invite limit exceeded",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
401: {
description: "Unauthorized",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
403: {
description: "Forbidden",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
404: {
description: "Not found",
content: {
"application/json": { schema: ErrorBodySchema },
},
},
},
}),
async (c) => {
let user: AuthedUser;
try {
user = getUser(c);
} catch (err) {
return c.json(
errorBody(
(err as EventsPluginError).code,
(err as Error).message
),
401
);
}
const { id } = c.req.valid("param");
const { user_ids } = c.req.valid("json");
if (user_ids.length > maxInvitesPerRequest) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.INVITE_LIMIT_EXCEEDED,
`user_ids length ${user_ids.length} exceeds maxInvitesPerRequest=${maxInvitesPerRequest}`
),
400
);
}
const existing = await eventRepository.findById(id);
if (existing === null) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.EVENT_NOT_FOUND,
"Event not found"
),
404
);
}
if (!(await eventRepository.isCreator(id, user.id))) {
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.FORBIDDEN,
"Only event creator can invite users"
),
403
);
}
const invitations = await rsvpRepository.createInvitations(
id,
user_ids
);
await eventRepository.updateAttendeeCount(
id,
"invited",
invitations.length
);
getEventsInstruments().invitationsSentTotal.add(invitations.length);
config.emitEvent?.({
type: "event:invitations:sent",
payload: { eventId: id, total: invitations.length },
});
return c.json({ invitations, total: invitations.length }, 201);
}
);
}

View File

@@ -0,0 +1,150 @@
# ics-export
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `ics-export` route module exposes `GET /:id/ics` on top of the
shared events `OpenAPIHono` mount, returning an RFC 5545
`text/calendar` document for a single event. It is the only ICS surface
the plugin ships in 0.1: calendar-feed subscriptions, push delivery,
and recurring-event expansion (RRULE) all remain deferred to follow-up
changes per the proposal's `## Out of scope` list.
The module's contract is narrow on purpose. It owns the auth /
visibility checks for `?with=rsvps`, the failure-counter mapping
(`events.ics.failed_total{reason}`), the success-counter emission, and
the `Content-Disposition: attachment` header. The actual VCALENDAR
construction is delegated wholesale to the pure
`ics-builder/ics-builder` module so byte-level tests can verify fold
and escape rules independently of route plumbing.
## Features
- `GET /:id/ics` — returns `text/calendar; charset=utf-8` with a single
`BEGIN:VCALENDAR` … `END:VCALENDAR` body containing one `VEVENT`.
- `GET /:id/ics?with=rsvps` — same body plus one `ATTENDEE` line per
RSVP row. Requires the requester to be the event creator; non-creators
receive `403`.
- `?with=<unknown>` — surfaces as `422` with
`EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY` via the route module's
`defaultHook`.
- Injectable `Clock` — `DTSTAMP` is sourced from the configured clock
so tests can pin a stable instant; production defaults to `Date.now`.
- Injectable `EventsInstruments` — production binds to the global
bundle via `getEventsInstruments()`; tests pass a fresh bundle to
assert counter emission against a known `MeterProvider`.
- `Content-Disposition: attachment; filename="event-<id>.ics"` on
every success response per RFC 6266 §4 — forces calendar-app download
rather than browser inline render.
## Usage: Basic
```ts
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createIcsExportRoutes,
EventRepository,
RsvpRepository,
} from "@suaveplan/api-plugin-events";
const app = new OpenAPIHono();
app.route(
"/events",
createIcsExportRoutes({
eventRepository: new EventRepository({ db }),
rsvpRepository: new RsvpRepository({ db }),
})
);
// GET /events/event:1/ics → 200, text/calendar
```
## Usage: Advanced
Inject a `Clock` for byte-stable `DTSTAMP` and pre-build a counter
bundle against a known `MeterProvider`:
```ts
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { OpenAPIHono } from "@hono/zod-openapi";
import {
createIcsExportRoutes,
getEventsInstruments,
resetEventsInstruments,
} from "@suaveplan/api-plugin-events";
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const provider = new MeterProvider({
readers: [new PeriodicExportingMetricReader({ exporter })],
});
metrics.setGlobalMeterProvider(provider);
resetEventsInstruments();
const app = new OpenAPIHono();
app.route(
"/events",
createIcsExportRoutes({
eventRepository,
rsvpRepository,
clock: () => Date.parse("2026-05-20T12:00:00Z"),
instruments: getEventsInstruments(),
})
);
```
## API Reference
### `createIcsExportRoutes(config: IcsExportRoutesConfig): OpenAPIHono`
Build a route module registering `GET /:id/ics`. Returns a fresh
`OpenAPIHono` instance the caller mounts on a parent app.
- `config.eventRepository: IEventRepository` — used for `findById` +
`isCreator`.
- `config.rsvpRepository: IRsvpRepository` — used for `findAttendees`
on the `?with=rsvps` variant.
- `config.clock?: Clock` — wall-clock source for `DTSTAMP`. Default:
`() => Date.now()`.
- `config.instruments?: EventsInstruments` — pre-bound counter bundle.
Default: lazy `getEventsInstruments()`.
### Response surface
| Status | Body shape | Counter |
|---|---|---|
| `200` | `text/calendar; charset=utf-8` VCALENDAR document | `events.ics.served_total{with=none\|rsvps}` |
| `401` | `{ error, code: "EVENTS_PLUGIN.UNAUTHENTICATED" }` | `events.ics.failed_total{reason=unauthenticated}` |
| `403` | `{ error, code: "EVENTS_PLUGIN.FORBIDDEN" }` | `events.ics.failed_total{reason=forbidden}` |
| `404` | `{ error, code: "EVENTS_PLUGIN.EVENT_NOT_FOUND" }` | `events.ics.failed_total{reason=not_found}` |
| `422` | `{ error, code: "EVENTS_PLUGIN.ICS_INVALID_QUERY" }` | `events.ics.failed_total{reason=invalid_query}` |
## Implementation Notes
- **Why a separate route module?** The shipped `events-routes` already
carries nine endpoints and a large `Variables` surface. Adding a
tenth route would push the file past the 300-line hard cap and
conflate the JSON-shaped route family with the binary text-stream
ICS endpoint. The split keeps both modules under the file-size
budget and lets the ICS module own its own `defaultHook` without
affecting the other handlers.
- **No `c.json` for the 200 path.** ICS bodies are not JSON. The route
uses `c.body(...)` with an explicit `Content-Type: text/calendar`
header so calendar applications detect the payload correctly.
- **422 is raised by the `defaultHook` on validation failure.** Hono's
default behaviour is `400`; we override to `422` because the request
reached a real handler but carries malformed query parameters —
semantically "unprocessable entity" rather than "malformed request".
- **Why no rate-limit / audit.** Both are scoped out at the plugin
level pending `api-plugin-rate-limit` and `api-plugin-audit`; the
ICS endpoint will inherit composition once those packages land.

View File

@@ -0,0 +1,342 @@
import { OpenAPIHono } from "@hono/zod-openapi";
import { afterEach, describe, expect, it } from "@suaveplan/testing/runner";
import { flushTestMetrics } from "../../../tests/setup/polyfills.js";
import {
getEventsInstruments,
resetEventsInstruments,
} from "../../otel/instruments.js";
import type { IEventRepository } from "../../repositories/event-repository/event-repository.types.js";
import type { IRsvpRepository } from "../../repositories/rsvp-repository/rsvp-repository.types.js";
import type { Event } from "../../schemas/event-schemas.js";
import type { Attendee } from "../../schemas/rsvp-schemas.js";
import { createIcsExportRoutes } from "./ics-export.js";
const baseEvent: Event = {
id: "event:1",
title: "Quarterly review",
description: "Planning + retro.",
start_time: "2026-06-01T10:00:00Z",
end_time: "2026-06-01T11:00:00Z",
timezone: "UTC",
creator: "user:1",
location: { name: "HQ" },
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
};
const baseAttendee: Attendee = {
id: "event_rsvp:1",
event: "event:1",
user: "user:42",
user_name: "Ada Lovelace",
user_email: "ada@example.com",
status: "going",
created_at: "2026-05-20T00:00:00Z",
updated_at: "2026-05-20T00:00:00Z",
};
interface RepoOverrides {
findById?: (id: string) => Promise<Event | null>;
isCreator?: (e: string, u: string) => Promise<boolean>;
}
function makeEventRepo(overrides: RepoOverrides = {}): IEventRepository {
return {
findAll: async () => [],
findById: overrides.findById ?? (async () => baseEvent),
create: async () => baseEvent,
update: async () => baseEvent,
delete: async () => undefined,
isCreator: overrides.isCreator ?? (async () => true),
updateAttendeeCount: async () => undefined,
} as IEventRepository;
}
interface RsvpOverrides {
findAttendees?: () => Promise<Attendee[]>;
}
function makeRsvpRepo(overrides: RsvpOverrides = {}): IRsvpRepository {
return {
findByEventAndUser: async () => null,
findAttendees: overrides.findAttendees ?? (async () => []),
upsert: async () => baseAttendee,
delete: async () => null,
createInvitations: async () => [],
countByStatus: async () => 0,
} as IRsvpRepository;
}
interface BuildOpts {
user?: { id: string } | null;
eventRepo?: IEventRepository;
rsvpRepo?: IRsvpRepository;
}
const FIXED_NOW = Date.parse("2026-05-20T12:34:56Z");
function buildApp(opts: BuildOpts = {}) {
const routes = createIcsExportRoutes({
eventRepository: opts.eventRepo ?? makeEventRepo(),
rsvpRepository: opts.rsvpRepo ?? makeRsvpRepo(),
clock: () => FIXED_NOW,
});
const user = opts.user === undefined ? { id: "user:1" } : opts.user;
const parent = new OpenAPIHono<{
Variables: { user: { id: string } | null | undefined };
}>();
parent.use(async (c, next) => {
c.set("user", user);
await next();
});
parent.route("/", routes);
return parent;
}
afterEach(() => {
resetEventsInstruments();
});
describe("GET /:id/ics — base variant", () => {
it("GET /:id/ics returns 401 without user", { timeout: 5000 }, async () => {
const app = buildApp({ user: null });
const res = await app.request("/event:1/ics");
expect(res.status).toBe(401);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("EVENTS_PLUGIN.UNAUTHENTICATED");
});
it("GET /:id/ics returns 200 with single VEVENT VCALENDAR", {
timeout: 5000,
}, async () => {
const app = buildApp();
const res = await app.request("/event:1/ics");
expect(res.status).toBe(200);
const text = await res.text();
expect(text.startsWith("BEGIN:VCALENDAR\r\n")).toBe(true);
expect(text.endsWith("END:VCALENDAR\r\n")).toBe(true);
const beginCount = (text.match(/BEGIN:VEVENT/g) ?? []).length;
const endCount = (text.match(/END:VEVENT/g) ?? []).length;
expect(beginCount).toBe(1);
expect(endCount).toBe(1);
expect(text).toContain("UID:event:1@suaveplan.events");
});
it("GET /:id/ics returns 404 when event missing", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ findById: async () => null }),
});
const res = await app.request("/missing/ics");
expect(res.status).toBe(404);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("EVENTS_PLUGIN.EVENT_NOT_FOUND");
});
it("GET /:id/ics sets Content-Disposition attachment header", {
timeout: 5000,
}, async () => {
const app = buildApp();
const res = await app.request("/event:1/ics");
expect(res.status).toBe(200);
expect(res.headers.get("content-disposition")).toBe(
'attachment; filename="event-event:1.ics"'
);
expect(res.headers.get("content-type")).toContain("text/calendar");
expect(res.headers.get("content-type")).toContain("charset=utf-8");
});
it("GET /:id/ics returns 422 for invalid with value", {
timeout: 5000,
}, async () => {
const app = buildApp();
const res = await app.request("/event:1/ics?with=bogus");
expect(res.status).toBe(422);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("EVENTS_PLUGIN.ICS_INVALID_QUERY");
});
});
describe("GET /:id/ics — with=rsvps variant", () => {
it("GET /:id/ics?with=rsvps emits one ATTENDEE per RSVP", {
timeout: 5000,
}, async () => {
const app = buildApp({
rsvpRepo: makeRsvpRepo({
findAttendees: async () => [
baseAttendee,
{ ...baseAttendee, id: "event_rsvp:2", status: "maybe" },
],
}),
});
const res = await app.request("/event:1/ics?with=rsvps");
expect(res.status).toBe(200);
const text = await res.text();
const matches = text.match(/ATTENDEE;[^\r\n]*/g);
expect(matches?.length).toBe(2);
});
it("GET /:id/ics?with=rsvps returns 403 for non-creator", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ isCreator: async () => false }),
});
const res = await app.request("/event:1/ics?with=rsvps");
expect(res.status).toBe(403);
const body = (await res.json()) as { code: string };
expect(body.code).toBe("EVENTS_PLUGIN.FORBIDDEN");
});
it("GET /:id/ics?with=rsvps returns 404 when event missing", {
timeout: 5000,
}, async () => {
const app = buildApp({
eventRepo: makeEventRepo({ findById: async () => null }),
});
const res = await app.request("/missing/ics?with=rsvps");
expect(res.status).toBe(404);
});
});
describe("GET /:id/ics — OTel emission", () => {
it("increments events.ics.served_total on success", {
timeout: 30000,
}, async () => {
resetEventsInstruments();
const app = buildApp();
const res = await app.request("/event:1/ics");
expect(res.status).toBe(200);
await res.text();
const snapshots = await flushTestMetrics();
const metrics = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) => sm.metrics)
);
const served = metrics.find(
(m) => m.descriptor.name === "events.ics.served_total"
);
expect(served).toBeDefined();
});
it("increments events.ics.failed_total on 404", {
timeout: 30000,
}, async () => {
resetEventsInstruments();
const app = buildApp({
eventRepo: makeEventRepo({ findById: async () => null }),
});
const res = await app.request("/missing/ics");
expect(res.status).toBe(404);
await res.json();
const snapshots = await flushTestMetrics();
const metrics = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) => sm.metrics)
);
const failed = metrics.find(
(m) => m.descriptor.name === "events.ics.failed_total"
);
expect(failed).toBeDefined();
});
it("increments events.ics.failed_total{reason=unauthenticated} when user missing", {
timeout: 30000,
}, async () => {
resetEventsInstruments();
const inst = getEventsInstruments();
// Construct routes with explicit instruments to verify both
// the explicit-injection path and the failure-counter wiring.
const app = new OpenAPIHono<{
Variables: { user: { id: string } | null | undefined };
}>();
app.use(async (c, next) => {
c.set("user", null);
await next();
});
app.route(
"/",
createIcsExportRoutes({
eventRepository: makeEventRepo(),
rsvpRepository: makeRsvpRepo(),
clock: () => FIXED_NOW,
instruments: inst,
})
);
const res = await app.request("/event:1/ics");
expect(res.status).toBe(401);
await res.json();
const snapshots = await flushTestMetrics();
const all = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) => sm.metrics)
);
const failed = all.find(
(m) => m.descriptor.name === "events.ics.failed_total"
);
expect(failed).toBeDefined();
});
it("increments events.ics.failed_total{reason=invalid_query} on bad with", {
timeout: 30000,
}, async () => {
resetEventsInstruments();
const app = buildApp();
const res = await app.request("/event:1/ics?with=bogus");
expect(res.status).toBe(422);
await res.json();
const snapshots = await flushTestMetrics();
const all = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) => sm.metrics)
);
const failed = all.find(
(m) => m.descriptor.name === "events.ics.failed_total"
);
expect(failed).toBeDefined();
});
it("increments events.ics.failed_total{reason=forbidden} on non-creator with=rsvps", {
timeout: 30000,
}, async () => {
resetEventsInstruments();
const app = buildApp({
eventRepo: makeEventRepo({ isCreator: async () => false }),
});
const res = await app.request("/event:1/ics?with=rsvps");
expect(res.status).toBe(403);
await res.json();
const snapshots = await flushTestMetrics();
const all = snapshots.flatMap((s) =>
s.scopeMetrics.flatMap((sm) => sm.metrics)
);
const failed = all.find(
(m) => m.descriptor.name === "events.ics.failed_total"
);
expect(failed).toBeDefined();
});
it("defaults to Date.now-backed clock when none is provided", {
timeout: 5000,
}, async () => {
resetEventsInstruments();
const app = new OpenAPIHono<{
Variables: { user: { id: string } | null | undefined };
}>();
app.use(async (c, next) => {
c.set("user", { id: "user:1" });
await next();
});
app.route(
"/",
createIcsExportRoutes({
eventRepository: makeEventRepo(),
rsvpRepository: makeRsvpRepo(),
})
);
const res = await app.request("/event:1/ics");
expect(res.status).toBe(200);
const text = await res.text();
// DTSTAMP is present and matches the expected UTC format.
expect(text).toMatch(/DTSTAMP:\d{8}T\d{6}Z\r\n/);
});
});

View File

@@ -0,0 +1,193 @@
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
import { EVENTS_PLUGIN_CODES } from "../../error-codes/error-codes.js";
import { buildVCalendar } from "../../ics/ics-builder/ics-builder.js";
import type { IcsAttendee } from "../../ics/ics-builder/ics-builder.types.js";
import {
type EventsInstruments,
getEventsInstruments,
} from "../../otel/instruments.js";
import { EventIdParamSchema } from "../../schemas/event-schemas.js";
import type { EventsRoutesVariables } from "../events-routes/events-routes.js";
import type { IcsExportRoutesConfig } from "./ics-export.types.js";
/** Allowed values for the `with` query parameter. */
const IcsWithSchema = z.enum(["rsvps"]).optional();
const IcsQuerySchema = z.object({ with: IcsWithSchema });
const ErrorBodySchema = z.object({ error: z.string(), code: z.string() });
type AuthedUser = { id: string } & Record<string, unknown>;
type FailureReason =
| "not_found"
| "forbidden"
| "unauthenticated"
| "invalid_query";
function errorBody(
code: string,
error: string
): { error: string; code: string } {
return { error, code };
}
function recordFailure(
instruments: EventsInstruments,
reason: FailureReason
): void {
instruments.icsFailedTotal.add(1, { reason });
}
function readUser(c: {
get: (key: "user") => AuthedUser | null | undefined;
}): AuthedUser | null {
const user = c.get("user");
return user === null || user === undefined ? null : user;
}
/**
* Build the ICS-export route module.
*
* Registers a single endpoint:
*
* - `GET /:id/ics` → `text/calendar; charset=utf-8` body with one
* `VCALENDAR` containing one `VEVENT`. Accepts an optional
* `?with=rsvps` query parameter — when present, the response also
* contains one `ATTENDEE` line per RSVP row and requires the
* requester to be the event creator. Invalid `with` values surface
* as `422` with `EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY`.
*
* Auth, lookup, and authorization errors all increment the
* `events.ics.failed_total{reason}` counter; the success path
* increments `events.ics.served_total{with}`.
*/
export function createIcsExportRoutes(
config: IcsExportRoutesConfig
): OpenAPIHono<{ Variables: EventsRoutesVariables }> {
const clock = config.clock ?? (() => Date.now());
const app = new OpenAPIHono<{ Variables: EventsRoutesVariables }>({
defaultHook: (result, c) => {
if (!result.success) {
const instruments =
config.instruments ?? getEventsInstruments();
recordFailure(instruments, "invalid_query");
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.ICS_INVALID_QUERY,
"Invalid ICS query parameters"
),
422
);
}
return undefined;
},
});
const route = createRoute({
method: "get",
path: "/{id}/ics",
tags: ["events", "ics"],
summary: "Export event as RFC 5545 iCalendar",
request: { params: EventIdParamSchema, query: IcsQuerySchema },
responses: {
200: {
description: "iCalendar body",
content: { "text/calendar": { schema: z.string() } },
},
401: {
description: "Unauthorized",
content: { "application/json": { schema: ErrorBodySchema } },
},
403: {
description: "Forbidden",
content: { "application/json": { schema: ErrorBodySchema } },
},
404: {
description: "Not found",
content: { "application/json": { schema: ErrorBodySchema } },
},
422: {
description: "Invalid query",
content: { "application/json": { schema: ErrorBodySchema } },
},
},
});
app.openapi(route, async (c) => {
const instruments = config.instruments ?? getEventsInstruments();
const user = readUser(c);
if (user === null) {
recordFailure(instruments, "unauthenticated");
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.UNAUTHENTICATED,
"Authentication required"
),
401
);
}
const { id } = c.req.valid("param");
const query = c.req.valid("query");
const withRsvps = query.with === "rsvps";
const event = await config.eventRepository.findById(id);
if (event === null) {
recordFailure(instruments, "not_found");
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.EVENT_NOT_FOUND,
"Event not found"
),
404
);
}
if (
withRsvps &&
!(await config.eventRepository.isCreator(id, user.id))
) {
recordFailure(instruments, "forbidden");
return c.json(
errorBody(
EVENTS_PLUGIN_CODES.FORBIDDEN,
"Only event creator may export RSVP roster"
),
403
);
}
const body = await (async () => {
if (withRsvps) {
const rows = await config.rsvpRepository.findAttendees(id);
const attendees: IcsAttendee[] = rows.map((row) => ({
id: row.id,
user: row.user,
user_name: row.user_name,
user_email: row.user_email,
status: row.status,
}));
return buildVCalendar({ event, attendees, nowMs: clock() });
}
return buildVCalendar({ event, nowMs: clock() });
})();
instruments.icsServedTotal.add(1, {
with: withRsvps ? "rsvps" : "none",
});
// Per RFC 6266 §4 — force calendar-app download instead of inline.
c.header(
"Content-Disposition",
`attachment; filename="event-${id}.ics"`
);
// OpenAPIHono's `c.body()` honours the explicit content-type
// header set here; the route's `200` response declaration above
// only describes the OpenAPI schema, not the wire content-type.
return c.body(body, 200, {
"Content-Type": "text/calendar; charset=utf-8",
});
});
return app;
}

View File

@@ -0,0 +1,22 @@
import type { Clock } from "@suaveplan/types";
import type { EventsInstruments } from "../../otel/instruments.js";
import type { IEventRepository } from "../../repositories/event-repository/event-repository.types.js";
import type { IRsvpRepository } from "../../repositories/rsvp-repository/rsvp-repository.types.js";
/**
* Dependencies handed to `createIcsExportRoutes`.
*
* The clock is injected so `DTSTAMP` is byte-stable in tests. The
* instruments slot is optional — when omitted the route resolves the
* global bundle via `getEventsInstruments()`, which is the production
* default. Tests typically pass a freshly-built bundle to assert
* emission against a known `MeterProvider`.
*/
export interface IcsExportRoutesConfig {
readonly eventRepository: IEventRepository;
readonly rsvpRepository: IRsvpRepository;
/** Wall-clock source for `DTSTAMP`. Defaults to `Date.now`. */
readonly clock?: Clock;
/** Optional pre-built OTel bundle. Defaults to the global singleton. */
readonly instruments?: EventsInstruments;
}

View File

@@ -0,0 +1,2 @@
export { createIcsExportRoutes } from "./ics-export.js";
export type { IcsExportRoutesConfig } from "./ics-export.types.js";

View File

@@ -0,0 +1,105 @@
# event-schemas
## Purpose
The package-local Zod schemas for the `event` entity surface. Owns
every input / output shape touching the events routes and
repositories: the persisted `EventSchema`, the create / update
request bodies, the list-query parameters, the path param shape,
the `EventLocationSchema` value object, and the `EventStatusSchema`
0..2 integer encoding. The `@suaveplan/api-schemas` package
ships only event-bus envelope schemas (`DomainEvent`,
`EventEnvelope`, `EventMetadata`); the event-management entity
schemas are owned here because no other package needs them.
The schemas double as the type sources — every exported `*Schema`
has a matching `type Foo = z.infer<typeof FooSchema>` so consumers
get a single source of truth for both runtime validation and
compile-time narrowing. The `CreateEventSchema` and
`UpdateEventSchema` carry a `.refine()` constraint that asserts
`end_time > start_time` at the parser boundary so the repository
never sees a malformed time pair.
## Features
- 11 schemas spanning persistence, request, response, and value
objects.
- `CreateEventSchema` / `UpdateEventSchema` enforce `end_time > start_time`
via `.refine()`.
- `EventStatusSchema` constrains status to `0..2` (draft / active /
cancelled) matching the upstream encoding.
- `EventQuerySchema` coerces query-string `limit` / `offset` /
`upcoming` / `past` to typed numbers / booleans.
- `EventLocationSchema` validates `(lat, lng)` coordinates against
geographic bounds.
## Usage: Basic Example
```ts
import { CreateEventSchema, EventSchema } from "@suaveplan/api-plugin-events";
const parsed = CreateEventSchema.parse({
title: "Meetup",
start_time: "2026-06-01T18:00:00Z",
end_time: "2026-06-01T22:00:00Z",
});
console.log(parsed.timezone); // "UTC"
```
## Usage: Advanced Example
```ts
import {
EventQuerySchema,
EventListResponseSchema,
UpdateEventSchema,
} from "@suaveplan/api-plugin-events";
// Coerce from URL search params
const url = new URL("https://api.example.com/events?limit=10&past=true");
const query = EventQuerySchema.parse(Object.fromEntries(url.searchParams));
// Build a typed response payload
const body = EventListResponseSchema.parse({
data: [
/* events from repository */
],
total: 42,
});
// Forbid time inversion at the boundary
const result = UpdateEventSchema.safeParse({
start_time: "2026-06-01T22:00:00Z",
end_time: "2026-06-01T18:00:00Z",
});
if (!result.success) {
// result.error.issues[0].path === ["end_time"]
}
```
## API Reference
- `EventStatusSchema: z.ZodNumber` — integer 0..2.
- `EventLocationSchema: z.ZodObject` — `{ name, address?, coordinates? }`.
- `AttendeeCountSchema: z.ZodObject` — `{ going, maybe, invited }`.
- `EventSchema: z.ZodObject` — the persisted event row.
- `CreateEventSchema: z.ZodEffects<...>` — POST `/` body.
- `UpdateEventSchema: z.ZodEffects<...>` — PUT `/{id}` body.
- `EventQuerySchema: z.ZodObject` — GET `/` query params.
- `EventIdParamSchema: z.ZodObject` — `{ id: string }`.
- `EventListResponseSchema: z.ZodObject` — GET `/` response.
Every schema exports a sibling `type Foo = z.infer<typeof FooSchema>`.
## Implementation Notes
`status` is encoded as an integer (`0=draft`, `1=active`,
`2=cancelled`) rather than a string enum to match the upstream
SurrealDB schema and to keep label cardinality low when emitted as
OTel attributes. The two cross-field `.refine()` rules
(`end_time > start_time`) are duplicated on `CreateEventSchema` and
`UpdateEventSchema` rather than factored to a shared helper because
the `UpdateEventSchema` variant must tolerate a single-field update
(`{ title }`) without raising the refinement, which means the rule
fires only when both `start_time` and `end_time` appear in the same
payload.

View File

@@ -0,0 +1,202 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AttendeeCountSchema,
CreateEventSchema,
EventIdParamSchema,
EventListResponseSchema,
EventLocationSchema,
EventQuerySchema,
EventSchema,
EventStatusSchema,
UpdateEventSchema,
} from "./event-schemas.js";
const VALID_EVENT = {
id: "event:1",
title: "T",
description: "D",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
timezone: "UTC",
creator: "user:1",
attendee_count: { going: 0, maybe: 0, invited: 0 },
status: 1,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
describe("EventStatusSchema", () => {
it("accepts 0..2", { timeout: 5000 }, () => {
expect(EventStatusSchema.parse(0)).toBe(0);
expect(EventStatusSchema.parse(1)).toBe(1);
expect(EventStatusSchema.parse(2)).toBe(2);
});
it("rejects out-of-range", { timeout: 5000 }, () => {
expect(EventStatusSchema.safeParse(-1).success).toBe(false);
expect(EventStatusSchema.safeParse(3).success).toBe(false);
expect(EventStatusSchema.safeParse(1.5).success).toBe(false);
});
});
describe("EventLocationSchema", () => {
it("parses minimal location", { timeout: 5000 }, () => {
expect(EventLocationSchema.parse({ name: "Venue" })).toEqual({
name: "Venue",
});
});
it("parses with coordinates", { timeout: 5000 }, () => {
const v = EventLocationSchema.parse({
name: "X",
coordinates: { lat: 0, lng: 0 },
});
expect(v.coordinates).toEqual({ lat: 0, lng: 0 });
});
it("rejects empty name", { timeout: 5000 }, () => {
expect(EventLocationSchema.safeParse({ name: "" }).success).toBe(false);
});
it("rejects out-of-range coords", { timeout: 5000 }, () => {
expect(
EventLocationSchema.safeParse({
name: "X",
coordinates: { lat: 999, lng: 0 },
}).success
).toBe(false);
});
});
describe("AttendeeCountSchema", () => {
it("applies defaults", { timeout: 5000 }, () => {
expect(AttendeeCountSchema.parse({})).toEqual({
going: 0,
maybe: 0,
invited: 0,
});
});
});
describe("EventSchema", () => {
it("parses a valid persisted event", { timeout: 5000 }, () => {
expect(EventSchema.parse(VALID_EVENT).id).toBe("event:1");
});
});
describe("CreateEventSchema", () => {
it("parses with defaults", { timeout: 5000 }, () => {
const parsed = CreateEventSchema.parse({
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
});
expect(parsed.timezone).toBe("UTC");
expect(parsed.status).toBe(1);
});
it("rejects end <= start", { timeout: 5000 }, () => {
const result = CreateEventSchema.safeParse({
title: "T",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T09:00:00Z",
});
expect(result.success).toBe(false);
});
it("rejects too-long title", { timeout: 5000 }, () => {
expect(
CreateEventSchema.safeParse({
title: "x".repeat(201),
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
}).success
).toBe(false);
});
it("rejects empty title", { timeout: 5000 }, () => {
expect(
CreateEventSchema.safeParse({
title: "",
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
}).success
).toBe(false);
});
it("rejects too-long description", { timeout: 5000 }, () => {
expect(
CreateEventSchema.safeParse({
title: "T",
description: "x".repeat(5001),
start_time: "2026-01-01T10:00:00Z",
end_time: "2026-01-01T12:00:00Z",
}).success
).toBe(false);
});
});
describe("UpdateEventSchema", () => {
it("accepts an empty object", { timeout: 5000 }, () => {
expect(UpdateEventSchema.parse({})).toEqual({});
});
it("rejects end <= start when both supplied", { timeout: 5000 }, () => {
expect(
UpdateEventSchema.safeParse({
start_time: "2026-01-01T12:00:00Z",
end_time: "2026-01-01T10:00:00Z",
}).success
).toBe(false);
});
it("passes when only one time supplied", { timeout: 5000 }, () => {
expect(
UpdateEventSchema.parse({ start_time: "2026-01-01T10:00:00Z" })
.start_time
).toBe("2026-01-01T10:00:00Z");
});
it("rejects too-long title", { timeout: 5000 }, () => {
expect(
UpdateEventSchema.safeParse({ title: "x".repeat(201) }).success
).toBe(false);
});
it("rejects empty title", { timeout: 5000 }, () => {
expect(UpdateEventSchema.safeParse({ title: "" }).success).toBe(false);
});
it("rejects too-long description", { timeout: 5000 }, () => {
expect(
UpdateEventSchema.safeParse({ description: "x".repeat(5001) })
.success
).toBe(false);
});
});
describe("EventQuerySchema", () => {
it("applies defaults", { timeout: 5000 }, () => {
const parsed = EventQuerySchema.parse({});
expect(parsed.limit).toBe(20);
expect(parsed.offset).toBe(0);
});
it("coerces strings", { timeout: 5000 }, () => {
const parsed = EventQuerySchema.parse({
limit: "5",
offset: "3",
} as unknown as {
limit: number;
offset: number;
});
expect(parsed.limit).toBe(5);
expect(parsed.offset).toBe(3);
});
it("rejects out-of-range limit", { timeout: 5000 }, () => {
expect(EventQuerySchema.safeParse({ limit: 0 }).success).toBe(false);
expect(EventQuerySchema.safeParse({ limit: 101 }).success).toBe(false);
});
});
describe("EventIdParamSchema", () => {
it("parses non-empty id", { timeout: 5000 }, () => {
expect(EventIdParamSchema.parse({ id: "abc" }).id).toBe("abc");
});
it("rejects empty id", { timeout: 5000 }, () => {
expect(EventIdParamSchema.safeParse({ id: "" }).success).toBe(false);
});
});
describe("EventListResponseSchema", () => {
it("parses a valid response", { timeout: 5000 }, () => {
const parsed = EventListResponseSchema.parse({ data: [VALID_EVENT] });
expect(parsed.data).toHaveLength(1);
});
});

View File

@@ -0,0 +1,139 @@
import z from "zod";
/**
* Event status — `0=draft`, `1=active`, `2=cancelled`.
*
* Three discrete states match the upstream upstream `EventStatusSchema`.
* The integer encoding survives a SurrealDB `INT` round-trip without
* lossy coercion and is small enough for label-cardinality in OTel.
*/
export const EventStatusSchema = z
.number()
.int()
.min(0)
.max(2)
.describe("Event status: 0=draft, 1=active, 2=cancelled");
/** Event location — name + optional address + optional coordinates. */
export const EventLocationSchema = z.object({
name: z.string().min(1, "Location name is required").max(200),
address: z.string().max(500).optional(),
coordinates: z
.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
})
.optional(),
});
export type EventLocation = z.infer<typeof EventLocationSchema>;
/** Attendee counters carried on every event document. */
export const AttendeeCountSchema = z.object({
going: z.number().int().min(0).default(0),
maybe: z.number().int().min(0).default(0),
invited: z.number().int().min(0).default(0),
});
export type AttendeeCount = z.infer<typeof AttendeeCountSchema>;
/** Persisted event document. */
export const EventSchema = z.object({
id: z.string(),
title: z.string().min(1).max(200),
description: z.string().optional(),
start_time: z.string(),
end_time: z.string(),
timezone: z.string().default("UTC"),
location: EventLocationSchema.optional(),
creator: z.string(),
attendee_count: AttendeeCountSchema,
status: EventStatusSchema,
created_at: z.string(),
updated_at: z.string(),
});
export type Event = z.infer<typeof EventSchema>;
/** Create-event request body. */
export const CreateEventSchema = z
.object({
title: z
.string()
.min(1, "Title is required")
.max(200, "Title must be less than 200 characters"),
description: z
.string()
.max(5000, "Description must be less than 5000 characters")
.optional(),
start_time: z.string().min(1, "start_time is required"),
end_time: z.string().min(1, "end_time is required"),
timezone: z.string().default("UTC"),
location: EventLocationSchema.optional(),
status: EventStatusSchema.optional().default(1),
})
.refine((data) => new Date(data.end_time) > new Date(data.start_time), {
message: "End time must be after start time",
path: ["end_time"],
});
export type CreateEvent = z.infer<typeof CreateEventSchema>;
/** Update-event request body (every field optional). */
export const UpdateEventSchema = z
.object({
title: z
.string()
.min(1, "Title is required")
.max(200, "Title must be less than 200 characters")
.optional(),
description: z
.string()
.max(5000, "Description must be less than 5000 characters")
.optional(),
start_time: z.string().min(1).optional(),
end_time: z.string().min(1).optional(),
timezone: z.string().optional(),
location: EventLocationSchema.optional(),
status: EventStatusSchema.optional(),
})
.refine(
(data) => {
if (data.start_time !== undefined && data.end_time !== undefined) {
return new Date(data.end_time) > new Date(data.start_time);
}
return true;
},
{
message: "End time must be after start time",
path: ["end_time"],
}
);
export type UpdateEvent = z.infer<typeof UpdateEventSchema>;
/** Query parameters for `GET /`. */
export const EventQuerySchema = z.object({
status: EventStatusSchema.optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
upcoming: z.coerce.boolean().optional(),
past: z.coerce.boolean().optional(),
});
export type EventQuery = z.infer<typeof EventQuerySchema>;
/** Path-param shape for `/{id}` routes. */
export const EventIdParamSchema = z.object({
id: z.string().min(1, "Event ID is required"),
});
export type EventIdParam = z.infer<typeof EventIdParamSchema>;
/** `GET /` response body. */
export const EventListResponseSchema = z.object({
data: z.array(EventSchema),
total: z.number().int().min(0).optional(),
});
export type EventListResponse = z.infer<typeof EventListResponseSchema>;

View File

@@ -0,0 +1,44 @@
export {
type AttendeeCount,
AttendeeCountSchema,
type CreateEvent,
CreateEventSchema,
type Event,
type EventIdParam,
EventIdParamSchema,
type EventListResponse,
EventListResponseSchema,
type EventLocation,
EventLocationSchema,
type EventQuery,
EventQuerySchema,
EventSchema,
EventStatusSchema,
type UpdateEvent,
UpdateEventSchema,
} from "./event-schemas.js";
export {
type EventsPluginConfig,
type EventsPluginConfigInput,
EventsPluginOptionsSchema,
} from "./options.js";
export {
type Attendee,
type AttendeeListResponse,
AttendeeListResponseSchema,
type AttendeeQuery,
AttendeeQuerySchema,
AttendeeSchema,
type CreateRsvp,
CreateRsvpSchema,
type InviteResponse,
InviteResponseSchema,
type InviteUsers,
InviteUsersSchema,
type Rsvp,
type RsvpResponse,
RsvpResponseSchema,
RsvpSchema,
type RsvpStatus,
RsvpStatusSchema,
} from "./rsvp-schemas.js";

View File

@@ -0,0 +1,101 @@
# options
## Purpose
The Zod schema for `createEventsPlugin`'s options bundle. Three
fields: `basePath` (mount prefix, default `/events`),
`enableNotifications` (reserved for a future
`@suaveplan/notification-channel` binding, default `false`),
`maxInvitesPerRequest` (runtime cap on the `/invite` endpoint,
default `50`, range `1..100`).
The schema is the boundary between caller input and the resolved
config that flows through every lifecycle hook. The plugin factory
calls `EventsPluginOptionsSchema.safeParse(options)` at construction
time and raises `EventsPluginError(INVALID_CONFIG)` on failure so an
invalid `basePath` or out-of-range `maxInvitesPerRequest` fails
before the descriptor leaves the factory and never reaches the
registry.
## Features
- Every field has a default — `EventsPluginOptionsSchema.parse({})`
produces the canonical resolved config.
- `basePath` is a non-empty string.
- `maxInvitesPerRequest` is an integer in `[1, 100]`.
- `EventsPluginConfigInput` (the `z.input` type) is partial-by-default
for the caller; `EventsPluginConfig` (the `z.infer` type) is
fully-populated for the runtime.
## Usage: Basic Example
```ts
import {
EventsPluginOptionsSchema,
type EventsPluginConfig,
} from "@suaveplan/api-plugin-events";
const config: EventsPluginConfig = EventsPluginOptionsSchema.parse({});
console.log(config.basePath); // "/events"
```
## Usage: Advanced Example
```ts
import {
EventsPluginOptionsSchema,
type EventsPluginConfigInput,
} from "@suaveplan/api-plugin-events";
function deriveConfig(env: Record<string, string | undefined>) {
const input: EventsPluginConfigInput = {
basePath: env.EVENTS_BASE_PATH ?? "/events",
enableNotifications: env.EVENTS_NOTIFY === "true",
maxInvitesPerRequest: env.EVENTS_INVITE_CAP
? Number.parseInt(env.EVENTS_INVITE_CAP, 10)
: 50,
};
const result = EventsPluginOptionsSchema.safeParse(input);
if (!result.success) {
throw new Error(
`events plugin misconfigured: ${result.error.message}`
);
}
return result.data;
}
```
## API Reference
### `EventsPluginOptionsSchema: z.ZodObject`
- `basePath: z.ZodDefault<z.ZodString>` — default `/events`.
- `enableNotifications: z.ZodDefault<z.ZodBoolean>` — default `false`.
- `maxInvitesPerRequest: z.ZodDefault<z.ZodNumber>` — int `[1, 100]`,
default `50`.
### `type EventsPluginConfig`
`z.infer<typeof EventsPluginOptionsSchema>` — the resolved, defaults-
applied shape passed to lifecycle hooks.
### `type EventsPluginConfigInput`
`z.input<typeof EventsPluginOptionsSchema>` — the caller-facing
partial shape accepted by `createEventsPlugin`.
## Implementation Notes
`enableNotifications` is reserved for the future
`@suaveplan/notification-channel` binding (see `## Out of scope` in
the proposal). The flag is validated and threaded through the
resolved config today so consumers can opt-in without a breaking
change later. The schema enforces the type contract; the binding
itself lands when the notification-channel package migrates.
`maxInvitesPerRequest` is intentionally capped at the schema layer
(100) below the body schema's hard cap (`InviteUsersSchema.user_ids`
max 100) so the route handler can return a structured
`INVITE_LIMIT_EXCEEDED` error instead of letting Zod raise a more
opaque `Array must contain at most 100 element(s)` validation
failure. The two caps coexist by design.

View File

@@ -0,0 +1,48 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { EventsPluginOptionsSchema } from "./options.js";
describe("EventsPluginOptionsSchema", () => {
it("parses empty input to defaults", { timeout: 5000 }, () => {
const parsed = EventsPluginOptionsSchema.parse({});
expect(parsed).toEqual({
basePath: "/events",
enableNotifications: false,
maxInvitesPerRequest: 50,
});
});
it("overrides defaults when provided", { timeout: 5000 }, () => {
const parsed = EventsPluginOptionsSchema.parse({
basePath: "/v1/events",
enableNotifications: true,
maxInvitesPerRequest: 10,
});
expect(parsed.basePath).toBe("/v1/events");
expect(parsed.enableNotifications).toBe(true);
expect(parsed.maxInvitesPerRequest).toBe(10);
});
it("rejects out-of-range maxInvitesPerRequest", { timeout: 5000 }, () => {
const result = EventsPluginOptionsSchema.safeParse({
maxInvitesPerRequest: 0,
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0]?.path).toContain(
"maxInvitesPerRequest"
);
}
});
it("rejects non-integer maxInvitesPerRequest", { timeout: 5000 }, () => {
const result = EventsPluginOptionsSchema.safeParse({
maxInvitesPerRequest: 5.5,
});
expect(result.success).toBe(false);
});
it("rejects empty basePath", { timeout: 5000 }, () => {
const result = EventsPluginOptionsSchema.safeParse({ basePath: "" });
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
import z from "zod";
/**
* Plugin options accepted by `createEventsPlugin`.
*
* Every field has a default so a no-arg call (`createEventsPlugin()`)
* produces a fully-resolved `EventsPluginConfig`.
*/
export const EventsPluginOptionsSchema = z.object({
/** Mount prefix for route registration. */
basePath: z.string().min(1).default("/events"),
/**
* Reserved for a future `@suaveplan/notification-channel` integration;
* see `openspec/changes/add-api-plugin-events/proposal.md`
* `## Out of scope`. Validated today so consumers configuring the
* flag now will not fail when the binding lands.
*/
enableNotifications: z.boolean().default(false),
/**
* Maximum number of users that a single `POST /:id/invite` may
* carry. The schema-level hard cap is 100 (declared in
* `rsvp-schemas.ts::InviteUsersSchema`); the runtime cap is set
* here per deployment.
*/
maxInvitesPerRequest: z.number().int().min(1).max(100).default(50),
});
/** Resolved config used by the plugin runtime. */
export type EventsPluginConfig = z.infer<typeof EventsPluginOptionsSchema>;
/** Caller-facing config — every field optional. */
export type EventsPluginConfigInput = z.input<typeof EventsPluginOptionsSchema>;

View File

@@ -0,0 +1,99 @@
# rsvp-schemas
## Purpose
Local Zod schemas for the RSVP / invitation surface. Owns the
persisted `RsvpSchema`, the `CreateRsvpSchema` request body for
`POST /:id/rsvp`, the `InviteUsersSchema` body for
`POST /:id/invite`, the `AttendeeQuerySchema` for the attendee
listing endpoint, and the joined `AttendeeSchema` that combines the
RSVP row with optional sparse user fields (`user_name`,
`user_email`, `user_image`).
The RSVP status is a four-value string enum (`invited`, `going`,
`maybe`, `not_going`) — not the integer encoding used for event
status — because RSVPs are read-heavy and the integer encoding gave
no real-world benefit when the upstream surface was originally
specced. The `InviteUsersSchema` enforces a 1..100 length range as
the schema-level hard cap; the runtime cap (default 50) is enforced
separately inside the `POST /:id/invite` handler so deployments can
narrow without recompiling.
## Features
- 9 schemas spanning persistence, request, response, and pagination.
- `RsvpStatusSchema` constrains status to the four documented values.
- `InviteUsersSchema` enforces `1 <= length <= 100` on `user_ids`
with non-empty-string members.
- `AttendeeSchema` extends `RsvpSchema` with optional sparse user
fields for joined listing endpoints.
- `AttendeeQuerySchema` coerces query-string parameters.
- `RsvpResponseSchema` carries a nullable `rsvp` field for
"no current RSVP" representations.
## Usage: Basic Example
```ts
import { CreateRsvpSchema, RsvpStatusSchema } from "@suaveplan/api-plugin-events";
const body = CreateRsvpSchema.parse({ status: "going" });
const isValid = RsvpStatusSchema.safeParse("yes").success; // false
```
## Usage: Advanced Example
```ts
import {
AttendeeListResponseSchema,
AttendeeQuerySchema,
InviteResponseSchema,
InviteUsersSchema,
} from "@suaveplan/api-plugin-events";
// Validate an invite body, then enforce the runtime cap separately
const parsed = InviteUsersSchema.parse({ user_ids: ["user:1", "user:2"] });
if (parsed.user_ids.length > config.maxInvitesPerRequest) {
return res.status(400).json({ error: "INVITE_LIMIT_EXCEEDED" });
}
// Build a paginated attendee response
const query = AttendeeQuerySchema.parse({ status: "going", limit: "100" });
const response = AttendeeListResponseSchema.parse({
data: [
/* joined attendees */
],
total: 42,
});
```
## API Reference
- `RsvpStatusSchema: z.ZodEnum<["invited", "going", "maybe", "not_going"]>`.
- `RsvpSchema: z.ZodObject` — persisted RSVP row.
- `CreateRsvpSchema: z.ZodObject` — `POST /:id/rsvp` body.
- `InviteUsersSchema: z.ZodObject` — `POST /:id/invite` body
(`user_ids: string[]`, length 1..100, non-empty members).
- `AttendeeQuerySchema: z.ZodObject` — pagination + status filter.
- `AttendeeSchema: z.ZodObject` — RSVP + sparse user fields.
- `AttendeeListResponseSchema: z.ZodObject` — `{ data, total }`.
- `RsvpResponseSchema: z.ZodObject` — `{ rsvp: RsvpSchema | null }`.
- `InviteResponseSchema: z.ZodObject` — `{ invitations, total }`.
Every schema exports a matching `type Foo = z.infer<typeof FooSchema>`.
## Implementation Notes
The schema-level cap on `InviteUsersSchema.user_ids` is 100 and the
runtime cap (`config.maxInvitesPerRequest`) defaults to 50. The two
limits coexist because the schema-level cap protects against
abusive payloads (10 000-element arrays that would still parse
correctly but exhaust the server) and the runtime cap encodes a
per-deployment policy. The route handler enforces the runtime cap
after the schema parse so the response code is `400` with
`EVENTS_PLUGIN.INVITE_LIMIT_EXCEEDED`, not `422`/`400` from Zod.
`AttendeeSchema` extends `RsvpSchema` with optional joined columns
because the suaveplan SurrealDB driver doesn't materialise FETCH
expansions into nested objects by default — the route handler reads
the raw projection from `findAttendees` which mixes RSVP fields with
optional joined user fields.

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
AttendeeListResponseSchema,
AttendeeQuerySchema,
AttendeeSchema,
CreateRsvpSchema,
InviteResponseSchema,
InviteUsersSchema,
RsvpResponseSchema,
RsvpSchema,
RsvpStatusSchema,
} from "./rsvp-schemas.js";
const VALID_RSVP = {
id: "event_rsvp:1",
event: "event:1",
user: "user:1",
status: "going" as const,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
describe("RsvpStatusSchema", () => {
it("accepts all four statuses", { timeout: 5000 }, () => {
for (const s of ["invited", "going", "maybe", "not_going"] as const) {
expect(RsvpStatusSchema.parse(s)).toBe(s);
}
});
it("rejects unknown status", { timeout: 5000 }, () => {
expect(RsvpStatusSchema.safeParse("yes").success).toBe(false);
});
});
describe("RsvpSchema", () => {
it("parses a valid row", { timeout: 5000 }, () => {
expect(RsvpSchema.parse(VALID_RSVP).id).toBe("event_rsvp:1");
});
});
describe("CreateRsvpSchema", () => {
it("parses a valid body", { timeout: 5000 }, () => {
expect(CreateRsvpSchema.parse({ status: "maybe" }).status).toBe(
"maybe"
);
});
});
describe("InviteUsersSchema", () => {
it("parses 1..100 users", { timeout: 5000 }, () => {
expect(
InviteUsersSchema.parse({ user_ids: ["user:1"] }).user_ids
).toHaveLength(1);
expect(
InviteUsersSchema.parse({
user_ids: Array.from({ length: 100 }, (_, i) => `user:${i}`),
}).user_ids
).toHaveLength(100);
});
it("InviteUsersSchema enforces user_ids length", { timeout: 5000 }, () => {
expect(InviteUsersSchema.safeParse({ user_ids: [] }).success).toBe(
false
);
expect(
InviteUsersSchema.safeParse({
user_ids: Array.from({ length: 101 }, (_, i) => `user:${i}`),
}).success
).toBe(false);
});
it("rejects empty user id strings", { timeout: 5000 }, () => {
expect(InviteUsersSchema.safeParse({ user_ids: [""] }).success).toBe(
false
);
});
});
describe("AttendeeQuerySchema", () => {
it("applies defaults", { timeout: 5000 }, () => {
const parsed = AttendeeQuerySchema.parse({});
expect(parsed.limit).toBe(50);
expect(parsed.offset).toBe(0);
});
it("accepts status filter", { timeout: 5000 }, () => {
expect(AttendeeQuerySchema.parse({ status: "going" }).status).toBe(
"going"
);
});
});
describe("AttendeeSchema", () => {
it("parses with optional joined fields", { timeout: 5000 }, () => {
const parsed = AttendeeSchema.parse({
...VALID_RSVP,
user_name: "Alice",
user_email: "alice@example.com",
});
expect(parsed.user_name).toBe("Alice");
});
});
describe("AttendeeListResponseSchema", () => {
it("parses with data + total", { timeout: 5000 }, () => {
expect(
AttendeeListResponseSchema.parse({ data: [VALID_RSVP], total: 1 })
.total
).toBe(1);
});
});
describe("RsvpResponseSchema", () => {
it("parses null rsvp", { timeout: 5000 }, () => {
expect(RsvpResponseSchema.parse({ rsvp: null }).rsvp).toBeNull();
});
it("parses a valid rsvp", { timeout: 5000 }, () => {
expect(RsvpResponseSchema.parse({ rsvp: VALID_RSVP }).rsvp).toEqual(
VALID_RSVP
);
});
});
describe("InviteResponseSchema", () => {
it("parses with invitations array", { timeout: 5000 }, () => {
expect(
InviteResponseSchema.parse({ invitations: [VALID_RSVP], total: 1 })
.total
).toBe(1);
});
});

View File

@@ -0,0 +1,83 @@
import z from "zod";
/** RSVP status enum. */
export const RsvpStatusSchema = z
.enum(["invited", "going", "maybe", "not_going"])
.describe("RSVP status");
export type RsvpStatus = z.infer<typeof RsvpStatusSchema>;
/** Persisted RSVP row. */
export const RsvpSchema = z.object({
id: z.string(),
event: z.string(),
user: z.string(),
status: RsvpStatusSchema,
created_at: z.string(),
updated_at: z.string(),
});
export type Rsvp = z.infer<typeof RsvpSchema>;
/** `POST /:id/rsvp` request body. */
export const CreateRsvpSchema = z.object({
status: RsvpStatusSchema,
});
export type CreateRsvp = z.infer<typeof CreateRsvpSchema>;
/**
* `POST /:id/invite` request body. The 100-cap is the schema-level
* hard bound; the plugin-level `maxInvitesPerRequest` is the runtime
* cap (default 50) and is enforced separately inside the route
* handler.
*/
export const InviteUsersSchema = z.object({
user_ids: z
.array(z.string().min(1, "User ID cannot be empty"))
.min(1, "At least one user ID is required")
.max(100, "Cannot invite more than 100 users at once"),
});
export type InviteUsers = z.infer<typeof InviteUsersSchema>;
/** `GET /:id/attendees` query parameters. */
export const AttendeeQuerySchema = z.object({
status: RsvpStatusSchema.optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
offset: z.coerce.number().int().min(0).default(0),
});
export type AttendeeQuery = z.infer<typeof AttendeeQuerySchema>;
/** Joined attendee row (RSVP + sparse user fields). */
export const AttendeeSchema = RsvpSchema.extend({
user_name: z.string().optional(),
user_email: z.string().email().optional(),
user_image: z.string().url().optional(),
});
export type Attendee = z.infer<typeof AttendeeSchema>;
/** `GET /:id/attendees` response. */
export const AttendeeListResponseSchema = z.object({
data: z.array(AttendeeSchema),
total: z.number().int().min(0),
});
export type AttendeeListResponse = z.infer<typeof AttendeeListResponseSchema>;
/** `GET /:id/my-rsvp` response. */
export const RsvpResponseSchema = z.object({
rsvp: RsvpSchema.nullable(),
});
export type RsvpResponse = z.infer<typeof RsvpResponseSchema>;
/** `POST /:id/invite` response. */
export const InviteResponseSchema = z.object({
invitations: z.array(RsvpSchema),
total: z.number().int().min(0),
});
export type InviteResponse = z.infer<typeof InviteResponseSchema>;

View File

@@ -0,0 +1,109 @@
# registry-augmentation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Module-augmentation file that adds the `eventRepository` and
`rsvpRepository` slots to `@suaveplan/api-plugin/service-registry`'s
`ServiceRegistry` interface. Importing this file for its side
effects — which the package root barrel does automatically — makes
`ctx.getService("eventRepository")` resolve to
`IEventRepository | undefined` for any downstream plugin's type
checker without a cast.
The file also re-declares the host-owned `app` and `db` slots so
this plugin's `onInit` / `onReady` can resolve them with full typing
even in consumer projects that have not explicitly imported
`@suaveplan/api`. TypeScript merges duplicate `declare module`
blocks, so the upstream `@suaveplan/api`'s authoritative declaration
remains in effect for consumers that import it directly — this file
is purely additive.
## Features
- Adds `eventRepository: IEventRepository` and
`rsvpRepository: IRsvpRepository` slots.
- Re-declares `app: OpenAPIHono` and `db?: SurrealAdapter` for
self-contained type resolution.
- Zero runtime cost — `declare module` produces no JavaScript output.
- Cumulative — does not conflict with `@suaveplan/api`'s own
augmentation.
## Usage: Basic Example
```ts
// Side-effect import — the package root barrel does this for you,
// but explicit imports are also supported.
import "@suaveplan/api-plugin-events";
import type { PluginContext } from "@suaveplan/api-plugin";
function inspect(ctx: PluginContext) {
const repo = ctx.getService("eventRepository");
// `repo` is typed as `IEventRepository | undefined`
}
```
## Usage: Advanced Example
```ts
import type { PluginContext } from "@suaveplan/api-plugin";
import "@suaveplan/api-plugin-events"; // load the augmentation
export function createSearchSyncPlugin() {
return {
name: "search-sync",
dependencies: ["events"],
async onReady(ctx: PluginContext) {
const events = ctx.getService("eventRepository");
if (events === undefined) {
throw new Error("eventRepository not available");
}
// Wire a search-index sync against the typed surface
ctx.eventBus.on("event:created", async ({ eventId }) => {
const event = await events.findById(eventId);
if (event !== null) await searchIndex.index(event);
});
},
};
}
```
## API Reference
This file exports no runtime values. The shape it produces is:
```ts
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
eventRepository: IEventRepository;
rsvpRepository: IRsvpRepository;
app: OpenAPIHono;
db?: SurrealAdapter;
}
}
```
Consumers reach the augmented type through any of:
- `import "@suaveplan/api-plugin-events"` (package root barrel —
loads the augmentation as a side effect).
- `import "@suaveplan/api-plugin-events/services/registry-augmentation"`
if a consumer wants the augmentation without pulling in the rest
of the package.
## Implementation Notes
The pattern is identical to `@suaveplan/api-plugin-search`'s
`services/registry-augmentation.ts` — every plugin owns the slots it
contributes, and the host-owned slots are re-declared cumulatively
so the plugin's own source files type-check without forcing a
consumer to import `@suaveplan/api` first. Cumulative module
augmentation is a non-negotiable feature of TypeScript and survives
both `vite-plugin-dts` and the canonical `tsc --build` pipeline.
The `db` slot is declared optional (`db?: SurrealAdapter`) to match
the upstream `@suaveplan/api` declaration; this plugin's `onInit`
treats `undefined` as the `SERVICE_NOT_AVAILABLE` failure case.

View File

@@ -0,0 +1,28 @@
/**
* ServiceRegistry module augmentation — declares the
* `eventRepository` and `rsvpRepository` slots on
* `@suaveplan/api-plugin/service-registry`'s `ServiceRegistry`
* interface so downstream plugins typed against the augmented
* registry see `ctx.getService("eventRepository")` resolve to
* `IEventRepository | undefined` without any cast.
*
* The host-side slots (`app`, `db`, `logger`, `eventBus`, `clock`) are
* owned by `@suaveplan/api`'s own augmentation module. Cumulative
* module augmentation merges everything into a single
* `ServiceRegistry` shape for consumers.
*/
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { IEventRepository } from "../repositories/event-repository/event-repository.types.js";
import type { IRsvpRepository } from "../repositories/rsvp-repository/rsvp-repository.types.js";
declare module "@suaveplan/api-plugin/service-registry" {
interface ServiceRegistry {
eventRepository: IEventRepository;
rsvpRepository: IRsvpRepository;
// Re-declare host slots that this plugin reads — module
// augmentation merges, so `@suaveplan/api`'s own augmentation
// remains authoritative for consumers that import it directly.
app: OpenAPIHono;
db?: import("@suaveplan/surrealdb").SurrealAdapter;
}
}

View File

@@ -0,0 +1,5 @@
export type {
EventRoutesConfig,
EventsPluginEvents,
EventsRouteEvent,
} from "./types.js";

View File

@@ -0,0 +1,109 @@
# types
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Public type definitions that don't live alongside their schemas or
implementations: the cross-plugin `EventsPluginEvents` map (the
typed payloads emitted on the shared `ctx.eventBus`), the
`EventsRouteEvent` discriminated union (the payload shape the route
handlers pass into the optional `emitEvent` callback), and the
`EventRoutesConfig` interface consumed by `createEventRoutes`.
`EventsPluginEvents` is the contract other plugins subscribe to.
Lifecycle topics (`events:initialized`, `events:ready`) carry no
payload; domain topics (`event:created`, `event:updated`,
`event:cancelled`, `rsvp:created`, `rsvp:cancelled`,
`event:invitations:sent`) carry strongly-typed payloads. The
upstream package wired these payloads through a private
`EventEmitter<EventPluginEventMap>` — the port lifts them
onto the host's shared bus so siblings (audit, notifications,
search-sync) can subscribe without poking at the events plugin's
internals.
## Features
- `EventsPluginEvents` — the canonical event-map for the plugin.
- `EventsRouteEvent` — discriminated union of route-side emissions.
- `EventRoutesConfig` — dependency surface of `createEventRoutes`.
- Zero runtime cost — types only.
## Usage: Basic Example
```ts
import type { EventsPluginEvents } from "@suaveplan/api-plugin-events";
function onEventCreated(payload: EventsPluginEvents["event:created"]) {
console.log(payload.eventId, payload.creatorId);
}
```
## Usage: Advanced Example
```ts
import type {
EventRoutesConfig,
EventsRouteEvent,
} from "@suaveplan/api-plugin-events";
import { createEventRoutes } from "@suaveplan/api-plugin-events";
const config: EventRoutesConfig = {
eventRepository,
rsvpRepository,
maxInvitesPerRequest: 50,
emitEvent: (event: EventsRouteEvent) => {
switch (event.type) {
case "event:created":
metrics.add("events", 1);
break;
case "rsvp:created":
metrics.add("rsvps", 1, { status: event.payload.status });
break;
default:
break;
}
},
};
const routes = createEventRoutes(config);
```
## API Reference
### `interface EventsPluginEvents`
The event-map for the plugin. Eight keys:
- `events:initialized: undefined`
- `events:ready: undefined`
- `event:created: { eventId: string; creatorId: string }`
- `event:updated: { eventId: string; updatedBy: string }`
- `event:cancelled: { eventId: string; cancelledBy: string }`
- `rsvp:created: { eventId: string; userId: string; status: string }`
- `rsvp:cancelled: { eventId: string; userId: string; oldStatus: string }`
- `event:invitations:sent: { eventId: string; total: number }`
### `type EventsRouteEvent`
Discriminated union over the six domain topics. Each variant has
`type: <topic>` and `payload: EventsPluginEvents[<topic>]`.
### `interface EventRoutesConfig`
- `eventRepository: IEventRepository`
- `rsvpRepository: IRsvpRepository`
- `maxInvitesPerRequest: number`
- `emitEvent?: (event: EventsRouteEvent) => void`
## Implementation Notes
The plugin's `onReady` builds `emitEvent` as a thin forwarder onto
`ctx.eventBus.emit(event.type, event.payload)`. The `EventBus`
implementation is non-typed at the call site (the typing comes from
the global event-map augmentation), so the forwarder's narrowing
happens at the call site of `createEventRoutes` — that's why the
route handlers always pass a literal `{ type, payload }` object
instead of constructing it dynamically. The discriminated union
makes the call site exhaustive against the eight topics.

View File

@@ -0,0 +1,59 @@
import type { IEventRepository } from "../repositories/event-repository/event-repository.types.js";
import type { IRsvpRepository } from "../repositories/rsvp-repository/rsvp-repository.types.js";
/**
* Typed event payloads emitted on the host's shared `ctx.eventBus`.
*
* Other plugins subscribing through `eventBus.on(topic, handler)`
* receive these payloads with full typing through module augmentation
* on `@suaveplan/api-plugin`.
*
* The local-emitter wart from the upstream package — a private
* `EventEmitter<EventPluginEventMap>` constructed inside the plugin
* factory — is replaced by `ctx.eventBus`, so the events become
* visible to siblings (notifications, audit, search index sync).
*/
export interface EventsPluginEvents {
"events:initialized": undefined;
"events:ready": undefined;
"event:created": { eventId: string; creatorId: string };
"event:updated": { eventId: string; updatedBy: string };
"event:cancelled": { eventId: string; cancelledBy: string };
"rsvp:created": { eventId: string; userId: string; status: string };
"rsvp:cancelled": { eventId: string; userId: string; oldStatus: string };
"event:invitations:sent": { eventId: string; total: number };
}
/**
* Union of every domain event the routes emit. The plugin passes a
* single `emitEvent(event)` callback into the route builder; the
* callback forwards to `ctx.eventBus.emit(event.type, event.payload)`
* which is the genesis-canonical pattern (see
* `@suaveplan/api-plugin-search`).
*/
export type EventsRouteEvent =
| { type: "event:created"; payload: EventsPluginEvents["event:created"] }
| { type: "event:updated"; payload: EventsPluginEvents["event:updated"] }
| {
type: "event:cancelled";
payload: EventsPluginEvents["event:cancelled"];
}
| { type: "rsvp:created"; payload: EventsPluginEvents["rsvp:created"] }
| {
type: "rsvp:cancelled";
payload: EventsPluginEvents["rsvp:cancelled"];
}
| {
type: "event:invitations:sent";
payload: EventsPluginEvents["event:invitations:sent"];
};
/** Dependencies handed to `createEventRoutes`. */
export interface EventRoutesConfig {
eventRepository: IEventRepository;
rsvpRepository: IRsvpRepository;
/** Runtime cap on `POST /:id/invite` `user_ids` length. */
maxInvitesPerRequest: number;
/** Forward a typed domain event to the host's `eventBus`. */
emitEvent?: (event: EventsRouteEvent) => void;
}

View File

@@ -0,0 +1,68 @@
-- @suaveplan/api-plugin-events — SurrealDB schema
--
-- This migration ships a slimmed-down version of the upstream
-- `event.surql` covering only the tables the repositories actually
-- read / write. The upstream PERMISSIONS rules, stored helper
-- functions, and the `event_update` table are scoped out per
-- `openspec/changes/add-api-plugin-events/proposal.md` because they
-- transitively depend on sibling plugins (site, group, notifications,
-- bitwise) that have not yet been migrated to genesis. Permission
-- enforcement lives at the route / repository layer in the meantime.
-- Event Table
DEFINE TABLE OVERWRITE event SCHEMAFULL;
DEFINE FIELD OVERWRITE title ON event TYPE string
ASSERT $value != NONE AND string::len(string::trim($value)) > 0;
DEFINE FIELD OVERWRITE description ON event TYPE option<string>;
DEFINE FIELD OVERWRITE creator ON event TYPE record<user>
ASSERT $value != NONE;
DEFINE FIELD OVERWRITE location ON event TYPE option<object>;
DEFINE FIELD OVERWRITE location.name ON event TYPE option<string>;
DEFINE FIELD OVERWRITE location.address ON event TYPE option<string>;
DEFINE FIELD OVERWRITE location.coordinates ON event TYPE option<object>;
DEFINE FIELD OVERWRITE location.coordinates.lat ON event TYPE option<float>;
DEFINE FIELD OVERWRITE location.coordinates.lng ON event TYPE option<float>;
DEFINE FIELD OVERWRITE start_time ON event TYPE datetime
ASSERT $value != NONE;
DEFINE FIELD OVERWRITE end_time ON event TYPE datetime
ASSERT $value != NONE AND $value > start_time;
DEFINE FIELD OVERWRITE timezone ON event TYPE string DEFAULT 'UTC';
DEFINE FIELD OVERWRITE status ON event TYPE int
ASSERT $value != NONE AND $value >= 0 AND $value <= 2
DEFAULT 1;
DEFINE FIELD OVERWRITE attendee_count ON event TYPE object;
DEFINE FIELD OVERWRITE attendee_count.going ON event TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE attendee_count.maybe ON event TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE attendee_count.invited ON event TYPE int DEFAULT 0;
DEFINE FIELD OVERWRITE created_at ON event TYPE datetime VALUE time::now();
DEFINE FIELD OVERWRITE updated_at ON event TYPE datetime VALUE time::now();
-- Event RSVP Table
DEFINE TABLE OVERWRITE event_rsvp SCHEMAFULL;
DEFINE FIELD OVERWRITE event ON event_rsvp TYPE record<event>
ASSERT $value != NONE;
DEFINE FIELD OVERWRITE user ON event_rsvp TYPE record<user>
ASSERT $value != NONE;
DEFINE FIELD OVERWRITE status ON event_rsvp TYPE string
ASSERT $value INSIDE ['invited', 'going', 'maybe', 'not_going']
DEFAULT 'invited';
DEFINE FIELD OVERWRITE created_at ON event_rsvp TYPE datetime VALUE time::now();
DEFINE FIELD OVERWRITE updated_at ON event_rsvp TYPE datetime VALUE time::now();
-- Indexes
DEFINE INDEX OVERWRITE event_start_idx ON event FIELDS start_time;
DEFINE INDEX OVERWRITE event_rsvp_unique_idx ON event_rsvp FIELDS event, user UNIQUE;

View File

@@ -0,0 +1,140 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { OpenAPIHono } from "@hono/zod-openapi";
import { createPluginContext } from "@suaveplan/api-plugin";
import { EventBus } from "@suaveplan/events";
import { SurrealAdapter } from "@suaveplan/surrealdb";
import {
afterAll,
beforeAll,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
import { createEventsPlugin } from "../../src/plugin/plugin.js";
import type { EventsPluginConfig } from "../../src/schemas/options.js";
let fixture: SurrealDBFixture | null = null;
let cfg: {
url: string;
namespace: string;
database: string;
username: string;
password: string;
} | null = null;
let adapter: SurrealAdapter | null = null;
const noopLogger = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
beforeAll(async () => {
fixture = new SurrealDBFixture();
cfg = await fixture.start();
adapter = new SurrealAdapter({
url: cfg.url,
namespace: cfg.namespace,
database: cfg.database,
username: cfg.username,
password: cfg.password,
});
await adapter.connect();
// Apply the migration manually — the plugin itself does not run
// migrations in this proposal (the database plugin owns that
// responsibility), so the test sets up the schema first.
const surql = await readFile(
join(__dirname, "..", "..", "surql", "tables", "event.surql"),
"utf8"
);
await adapter.query(surql);
}, 180_000);
afterAll(async () => {
if (adapter !== null) {
await adapter.disconnect();
adapter = null;
}
if (fixture !== null) {
await fixture.stop();
fixture = null;
}
}, 60_000);
describe("@suaveplan/api-plugin-events integration", () => {
it("boots, creates event, records rsvp, increments count, cleans up", {
timeout: 180_000,
}, async () => {
if (adapter === null) throw new Error("fixture not ready");
// Seed creator + invitee in the user table required by the
// foreign-key constraint on event.creator / event_rsvp.user.
// The repositories only use `CREATE event SET creator = user:1`;
// SurrealDB v3 allows the record link to be soft until
// referenced, so we don't need to materialise the row, but
// creating them gives a more lifelike round-trip.
await adapter.query(
"CREATE user:creator SET name = 'C'; CREATE user:invitee SET name = 'I';"
);
// Boot the plugin.
const app = new OpenAPIHono();
const services = new Map<string, unknown>([
["db", adapter],
["app", app],
]);
const plugin = createEventsPlugin({ basePath: "/events" });
const ctx = createPluginContext<EventsPluginConfig>({
logger: noopLogger,
eventBus: new EventBus(),
signal: new AbortController().signal,
config: plugin.defaults as EventsPluginConfig,
services,
});
await plugin.onInit?.(ctx);
await plugin.onReady?.(ctx);
const eventRepo = ctx.getService("eventRepository");
const rsvpRepo = ctx.getService("rsvpRepository");
expect(eventRepo).toBeDefined();
expect(rsvpRepo).toBeDefined();
if (eventRepo === undefined || rsvpRepo === undefined) return;
const event = await eventRepo.create("user:creator", {
title: "Integration Test",
start_time: "2026-06-01T10:00:00Z",
end_time: "2026-06-01T12:00:00Z",
timezone: "UTC",
status: 1,
});
expect(event.title).toBe("Integration Test");
const found = await eventRepo.findById(String(event.id));
expect(found).not.toBeNull();
const rsvp = await rsvpRepo.upsert(String(event.id), "user:invitee", {
status: "going",
});
expect(rsvp.status).toBe("going");
await eventRepo.updateAttendeeCount(String(event.id), "going", 1);
const after = await eventRepo.findById(String(event.id));
expect(after?.attendee_count.going).toBeGreaterThanOrEqual(1);
const oldStatus = await rsvpRepo.delete(
String(event.id),
"user:invitee"
);
expect(oldStatus).toBe("going");
await eventRepo.delete(String(event.id));
const gone = await eventRepo.findById(String(event.id));
expect(gone).toBeNull();
await plugin.onClose?.(ctx);
});
});

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/api-plugin-events.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any
* source module imports so the meter resolved on first call binds to
* it. Tests that need fresh metric state per case call
* `resetEventsInstruments()` and `flushTestMetrics()` themselves.
*/
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter: testInMemoryMetricExporter,
exportIntervalMillis: 60_000,
});
export const testMeterProvider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(testMeterProvider);
export async function flushTestMetrics() {
await testMeterProvider.forceFlush();
const collected = testInMemoryMetricExporter.getMetrics();
testInMemoryMetricExporter.reset();
return collected;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-plugin-events",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^@opentelemetry\//,
/^@hono\//,
/^node:/,
"hono",
"tslib",
"zod",
],
},
},
});

View File

@@ -0,0 +1,33 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 60000,
hookTimeout: 180000,
},
});