first commit
This commit is contained in:
@@ -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,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.
|
||||
119
docs/api-stack/api/api-plugin-events/src/otel/instruments.md
Normal file
119
docs/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.
|
||||
112
docs/api-stack/api/api-plugin-events/src/plugin/plugin.md
Normal file
112
docs/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,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,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,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,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,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.
|
||||
101
docs/api-stack/api/api-plugin-events/src/schemas/options.md
Normal file
101
docs/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,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,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.
|
||||
109
docs/api-stack/api/api-plugin-events/src/types/types.md
Normal file
109
docs/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.
|
||||
Reference in New Issue
Block a user