Initial Commit
This commit is contained in:
112
packages/api-stack/api/api-plugin-events/README.md
Normal file
112
packages/api-stack/api/api-plugin-events/README.md
Normal 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.
|
||||
|
||||
140
packages/api-stack/api/api-plugin-events/package.json
Normal file
140
packages/api-stack/api/api-plugin-events/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type { EventsPluginErrorCode } from "./error-codes.js";
|
||||
export {
|
||||
EVENTS_PLUGIN_CODES,
|
||||
EventsPluginError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
107
packages/api-stack/api/api-plugin-events/src/index.ts
Normal file
107
packages/api-stack/api/api-plugin-events/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type EventsInstruments,
|
||||
getEventsInstruments,
|
||||
resetEventsInstruments,
|
||||
} from "./instruments.js";
|
||||
119
packages/api-stack/api/api-plugin-events/src/otel/instruments.md
Normal file
119
packages/api-stack/api/api-plugin-events/src/otel/instruments.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
112
packages/api-stack/api/api-plugin-events/src/plugin/plugin.md
Normal file
112
packages/api-stack/api/api-plugin-events/src/plugin/plugin.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
154
packages/api-stack/api/api-plugin-events/src/plugin/plugin.ts
Normal file
154
packages/api-stack/api/api-plugin-events/src/plugin/plugin.ts
Normal 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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { EventRepository } from "./event-repository.js";
|
||||
export type {
|
||||
EventRepositoryConfig,
|
||||
IEventRepository,
|
||||
} from "./event-repository.types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export { RsvpRepository } from "./rsvp-repository.js";
|
||||
export type {
|
||||
IRsvpRepository,
|
||||
RsvpRepositoryConfig,
|
||||
} from "./rsvp-repository.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createIcsExportRoutes } from "./ics-export.js";
|
||||
export type { IcsExportRoutesConfig } from "./ics-export.types.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
101
packages/api-stack/api/api-plugin-events/src/schemas/options.md
Normal file
101
packages/api-stack/api/api-plugin-events/src/schemas/options.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type {
|
||||
EventRoutesConfig,
|
||||
EventsPluginEvents,
|
||||
EventsRouteEvent,
|
||||
} from "./types.js";
|
||||
109
packages/api-stack/api/api-plugin-events/src/types/types.md
Normal file
109
packages/api-stack/api/api-plugin-events/src/types/types.md
Normal 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.
|
||||
59
packages/api-stack/api/api-plugin-events/src/types/types.ts
Normal file
59
packages/api-stack/api/api-plugin-events/src/types/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-events/tsconfig.json
Normal file
12
packages/api-stack/api/api-plugin-events/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-plugin-events/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-plugin-events/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
38
packages/api-stack/api/api-plugin-events/vite.config.ts
Normal file
38
packages/api-stack/api/api-plugin-events/vite.config.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
33
packages/api-stack/api/api-plugin-events/vitest.config.ts
Normal file
33
packages/api-stack/api/api-plugin-events/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user