diff --git a/AGENTS.md b/AGENTS.md index 6796af489..dd5b57ed2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,8 @@ revalidateTag("products", { revalidate: 3600 }); - Make sure to use the correct ARIA roles and attributes. - Remember to use the "sr-only" Tailwind class for screen reader only text. - Add alt text for all images, unless they are decorative or it would be repetitive for screen readers. + - Add emit an event for important actions like create, update, delete, etc. to allow other components to react to the changes and add to docs (apps/docs/content/docs/dev/events/built-in-events.mdx) + - Use events to communicate between components instead of prop drilling or using context. # Design @@ -67,6 +69,15 @@ revalidateTag("products", { revalidate: 3600 }); # Documentation - Don't use big comments. Remember that code is self-documenting. +- If should be simple. if docs requires image to better understand, add comment: + +```markdown +// Image prompt: {here_prompt_to_generate_image} +``` + +- Always write documentation for all new features. +- Keep docs simple and easy to understand. +- Use funny and friendly tone in docs, but don't overdo it. # Testing diff --git a/apps/docs/content/docs/dev/events/built-in-events.mdx b/apps/docs/content/docs/dev/events/built-in-events.mdx new file mode 100644 index 000000000..453e89d64 --- /dev/null +++ b/apps/docs/content/docs/dev/events/built-in-events.mdx @@ -0,0 +1,201 @@ +--- +title: Built-in Events +description: Reference of the events emitted by VitNode core and first-party plugins, with payloads and real use cases. +--- + +Reference of every event VitNode and its first-party plugins emit today. Listen +to any of them from your own plugin with +[`buildEventListener`](/docs/dev/events#listening-to-an-event) - no imports from +the emitting plugin are needed, the event map is global. + +| Event | Payload | Emitted when | +| ----------------------- | ---------------------------------------- | ------------------------------------------------------- | +| `user.created` | `{ userId, email, name, emailVerified }` | A user is created - sign-up, AdminCP, or SSO first sign-in | +| `user.updated` | `{ userId, email, name }` | A user is edited in the AdminCP (profile or roles) | +| `user.deleted` | `{ userId, email }` | _Declared only_ - core has no user deletion flow yet | +| `role.created` | `{ roleId }` | A role is created in the AdminCP | +| `role.updated` | `{ roleId }` | A role is edited in the AdminCP | +| `role.deleted` | `{ roleId }` | _Declared only_ - core has no role deletion flow yet | +| `blog.post.created` | `{ postId, categoryId }` | A blog post is created | +| `blog.post.updated` | `{ postId, categoryId }` | A blog post is edited | +| `blog.post.deleted` | `{ postId, categoryId }` | A blog post is deleted | +| `blog.category.created` | `{ categoryId }` | A blog category is created | +| `blog.category.updated` | `{ categoryId }` | A blog category is edited | +| `blog.category.deleted` | `{ categoryId, postIds }` | A blog category (and its posts, via cascade) is deleted | + +## Core + +### user.created + +Emitted after a user row is committed to `core_users`, from every creation +path: the public sign-up form, user creation in the AdminCP, and the first +sign-in through an [SSO provider](/docs/dev/sso). + +import { TypeTable } from "fumadocs-ui/components/type-table"; + + + +**Use cases:** send a welcome or verification email (dispatch a +[queue task](/docs/dev/advanced/queue) so it retries), subscribe the user to a +newsletter audience, provision plugin-owned data (a profile row, default +settings), or notify moderators about new registrations via +`c.get("realtime")`. + +```ts title="Example: welcome email listener" +export const welcomeListener = buildEventListener({ + event: "user.created", + name: "send-welcome-email", + handler: async (c, payload) => { + await c.get("queue").dispatch({ + name: "send-welcome-email", + payload: { userId: payload.userId, email: payload.email }, + }); + }, +}); +``` + +### user.updated + +Emitted after a user is edited in the AdminCP - profile fields (email, name, +name code) and/or role assignments. The payload carries the user's **current** +values after the update. + + + +**Use cases:** sync the user's identity into an external system (CRM, mailing +list), invalidate plugin-owned caches keyed by user, or audit-log staff edits +using the envelope's `actor`. + +### role.created / role.updated + +Emitted after a role is created or edited in the AdminCP (including its +translated names, which live in `core_languages_words`). + + + +**Use cases:** provision plugin-side permission defaults for a new role, or +refresh externally-cached permission matrices when a role changes. + +### user.deleted / role.deleted (declared only) + +These events exist in the `VitNodeEvents` map so listeners and payloads are +already typed, but **core never emits them today** - there is no user or role +deletion flow yet. They are the agreed-upon names for plugins that implement +deletion themselves, and core will emit them once deletion lands. + +## Blog (`@vitnode/blog`) + +### blog.category.created / blog.category.updated + +Emitted after a category (and its translated titles) is created or edited in +the AdminCP. + + + +**Use cases:** keep navigation menus or externally-cached category trees in +sync, or notify an external CMS/feed of taxonomy changes. + +### blog.post.created / blog.post.updated / blog.post.deleted + +Emitted from the AdminCP post routes after the post (and its translations and +search index entries) are written. + + + +**Use cases:** push a realtime "new post" notification to subscribers, ping a +webhook (via a queue task) that shares the post to social media, invalidate an +external cache/CDN, or keep plugin-owned derived data (reading lists, related +posts) in sync. + +### blog.category.deleted + +Emitted after a category is deleted. Deleting a category cascade-deletes its +posts at the database level, so the payload carries the ids of the posts that +were removed with it. + + + +**Use cases:** the blog plugin itself ships a listener on this event +(`cleanup-category-search`) that removes the cascade-deleted posts from the +search index - a good template for cleaning up any data your plugin keys by +post id. + +## Deliberately not emitted (yet) + +High-frequency or consumer-less events are added only when a listener needs +them, to keep the catalog meaningful: there is currently no `user.signedIn`, +`user.passwordResetRequested`, or `file.uploaded`. If you need one of these, +open an issue or PR - adding an event is a one-line `emit` plus an entry in the +`VitNodeEvents` map. (`user.deleted` and `role.deleted` are a special case: +they are declared in the map already, but wait on core growing deletion flows.) diff --git a/apps/docs/content/docs/dev/events/custom-adapter.mdx b/apps/docs/content/docs/dev/events/custom-adapter.mdx new file mode 100644 index 000000000..eddf3e6a8 --- /dev/null +++ b/apps/docs/content/docs/dev/events/custom-adapter.mdx @@ -0,0 +1,124 @@ +--- +title: Custom Adapter +description: Replace the in-process event transport with your own adapter, e.g. a message broker for cross-instance delivery. +--- + +The transport behind `c.get("events").emit()` is pluggable, following the same +pattern as [search](/docs/dev/search), storage, and email adapters. The default +**Local** adapter delivers in-process only; a custom adapter can publish events +to a broker (Redis Streams, RabbitMQ, NATS, ...) so every instance can react. + +An adapter implements a single method: + +```ts +import type { Context } from "hono"; +import type { + EventEnvelope, + EventEmitResult, +} from "@vitnode/core/api/models/events"; + +export interface EventsApiPlugin { + name: string; + publish: (c: Context, envelope: EventEnvelope) => Promise; +} +``` + +## Usage + + + + +### Create your custom adapter + +As an example, an adapter that publishes every event to a message broker +instead of running listeners in-process: + +```ts +import type { + EventsApiPlugin, + EventEnvelope, +} from "@vitnode/core/api/models/events"; + +export const MyBrokerEventsAdapter = (): EventsApiPlugin => ({ + name: "my-broker", + publish: async (c, envelope) => { + await broker.publish("vitnode:events", JSON.stringify(envelope)); // [!code ++] + + // Delivery happens out-of-band - report "queued", not "delivered". + return { + eventId: envelope.eventId, + status: "queued", + delivered: 0, + failures: [], + }; + }, +}); +``` + + + A broker adapter returns `status: "queued"`: listeners run out-of-band, so + `delivered` and `failures` say nothing about them. The consumer side of your + broker is responsible for running the registered listeners + (`c.get("core").events.listeners`) on each instance. + + + + + + +### Integrate the adapter into your application + +```ts title="src/vitnode.api.config.ts" +import { MyBrokerEventsAdapter } from "./path/to/your/custom/events.adapter"; + +export const vitNodeApiConfig = buildApiConfig({ + // [!code ++] + events: { adapter: MyBrokerEventsAdapter() }, +}); +``` + + + + + +### Restart server + +After making these changes, stop your server (if it's running) and restart it +to apply the new configuration. + +import { Tab, Tabs } from "fumadocs-ui/components/tabs"; + + + +```bash tab="bun" +bun dev +``` + +```bash tab="pnpm" +pnpm dev +``` + +```bash tab="npm" +npm run dev +``` + + + +Every `emit()` call in core and plugins now goes through your adapter - no emit +site changes needed. + + + + + +## Contract your adapter must keep + +- **Never let `publish` throw for expected failures** if you can help it - but + if it does, `emit()` still resolves: the model catches the error, logs it to + `core_logs`, and reports it in `result.failures`. +- **Envelopes are JSON-serializable** by convention (payloads are documented as + plain JSON), so `JSON.stringify(envelope)` is safe; `emittedAt` serializes to + an ISO string. +- **Preserve ordering per event name** if your listeners rely on it - the Local + adapter runs listeners sequentially in registration order, and listener code + written against it may assume that. diff --git a/apps/docs/content/docs/dev/events/index.mdx b/apps/docs/content/docs/dev/events/index.mdx new file mode 100644 index 000000000..e988d8a2e --- /dev/null +++ b/apps/docs/content/docs/dev/events/index.mdx @@ -0,0 +1,299 @@ +--- +title: Events +description: Emit typed domain events and react to them from any plugin, with per-listener error isolation and a pluggable transport. +icon: Radio +--- + +Events let plugins react to things happening elsewhere in your app - "a user +signed up", "a blog category was deleted" - without the emitting code knowing +who is listening. An event is a typed name + payload; listeners are plain +handlers registered by modules, exactly like [queue tasks](/docs/dev/advanced/queue) +and cron jobs. + +```ts +await c.get("events").emit("user.created", { + userId: user.id, + email: user.email, + name: user.name, + emailVerified: user.emailVerified, +}); +``` + +See [Built-in Events](/docs/dev/events/built-in-events) for the events VitNode +and its plugins already emit. + +## How it works + +```text +c.get("events").emit(name, payload) + 1. build an envelope (eventId, emittedAt, actor, emitting pluginId) + 2. hand it to the configured adapter (Local by default) + 3. Local adapter: run matching listeners sequentially, in registration order + · a listener that throws is logged to core_logs - the rest still run + 4. resolve with an EventEmitResult { eventId, status, delivered, failures } +``` + + + The default **Local** adapter runs listeners in-process, inside the request + that emitted the event, on that instance only. It is **not** distributed event + delivery: with multiple instances, only the emitting instance runs listeners. + The `events.adapter` config slot exists so a broker transport (Redis Streams, + RabbitMQ, NATS, ...) can be plugged in later without changing any `emit` call - + see [Custom Adapter](/docs/dev/events/custom-adapter). + + +## Listening to an event + +A listener is a named handler bound to one event, registered in a module - the +same shape as `queueTasks` and `cronJobs`. + + + +### Create the listener + +The handler receives the request context, the typed payload, and the full +envelope. This is the real listener shipped with the blog plugin - it removes +search index rows of posts that were cascade-deleted with their category: + +```ts title="plugins/blog/src/api/lib/events.ts" +import { buildEventListener } from "@vitnode/core/api/lib/events"; + +export const cleanupCategorySearchListener = buildEventListener({ + event: "blog.category.deleted", + name: "cleanup-category-search", + description: + "Remove search index rows of posts cascade-deleted with a category", + handler: async (c, payload) => { + for (const postId of payload.postIds) { + await c.get("search").delete("blog_post", postId); + } + }, +}); +``` + + + +### Register it in a module + +```ts title="modules/admin/admin.module.ts" +import { buildModule } from "@vitnode/core/api/lib/module"; +import { CONFIG_PLUGIN } from "@/const"; + +// [!code ++] +import { cleanupCategorySearchListener } from "../../lib/events"; + +export const adminModule = buildModule({ + pluginId: CONFIG_PLUGIN.pluginId, + name: "admin", + routes: [], + // [!code ++] + events: [cleanupCategorySearchListener], +}); +``` + + + Like `cronJobs` and `queueTasks`, `events` are only collected from the modules + passed directly to `buildApiPlugin` - listeners declared on nested submodules + are not registered. + + +Ownership is tracked automatically: every listener carries the `pluginId` and +module it was registered by, which is used for failure attribution and logging. +Removing a plugin from `vitnode.api.config.ts` removes its listeners - there is +nothing else to clean up. + + + + +## Emitting an event + +Emit from any route or model via `c.get("events")` - **after** the writes the +event describes have succeeded, so listeners never see an event for data that +doesn't exist: + +```ts title="modules/admin/categories/routes/delete.route.ts" +const result = await c + .get("db") + .delete(blog_categories) + .where(eq(blog_categories.id, id)) + .returning(); + +if (result.length === 0) { + throw new HTTPException(404); +} + +await c.get("events").emit("blog.category.deleted", { + categoryId: id, + postIds: posts.map(post => post.id), +}); +``` + +`emit()` **never throws**. A broken listener in one plugin cannot break the +request or other plugins' listeners - failures are caught per listener, logged, +and reported in the result. If you care whether listeners succeeded, inspect it: + +```ts +const result = await c.get("events").emit("blog.category.deleted", payload); + +if (result.failures.length > 0) { + // each failure: { pluginId, module, listener, error } +} +``` + +### EventEmitResult + +import { TypeTable } from "fumadocs-ui/components/type-table"; + + + +### The envelope + +Listeners receive the payload directly plus the full envelope as a third +argument: + + + +## Execution and failure semantics + +- Listeners run **sequentially**, in deterministic registration order: core + first, then your `plugins` array order, then module order, then the `events` + array order within the module. +- Each listener is awaited. A slow listener delays the request that emitted the + event (see the queue tip below). +- A listener that throws is **isolated**: the error is written to `core_logs` + (visible in **AdminCP → Logs**) with `pluginId:module:listener` attribution, + and the remaining listeners still run. +- `emit()` resolves with the result - it never rejects, even if every listener + (or the adapter itself) fails. + + + Listeners run inline and are not retried. For work that should survive a crash + or retry on failure, keep the listener thin and dispatch a + [queue task](/docs/dev/advanced/queue) from it - the queue adds persistence, + backoff, and retries. + +```ts +handler: async (c, payload) => { + await c.get("queue").dispatch({ + name: "send-welcome-email", + payload: { userId: payload.userId }, + }); +}; +``` + + + +## Events and transactions + +Emit **after** your awaited writes succeed. Because a route handler that throws +before `emit()` never emits, listeners can trust the event describes committed +data. + +If you wrap writes in `db.transaction`, emit **after the transaction callback +returns** - never inside it. Inside the callback the data is not committed yet, +and the transaction may still roll back: + +```ts +const post = await c.get("db").transaction(async tx => { + // ...writes on tx + return created; +}); + +// ✅ transaction committed - safe to emit +await c.get("events").emit("blog.post.created", { postId: post.id, ... }); +``` + +There is no outbox in the first version: if the process crashes between the +commit and the emit, the event is lost. Design listeners so a missed event is +recoverable (e.g. the search rebuild exists for exactly this) rather than +load-bearing for correctness. + +## Adding your own events + +Events are typed through a single global map, `VitNodeEvents`. Plugins extend +it with module augmentation, then emit and listen with full type safety - other +plugins see your events too. + +```ts title="plugins/my-plugin/src/api/lib/events.ts" +declare module "@vitnode/core/api/models/events" { + interface VitNodeEvents { + "shop.order.placed": { + orderId: number; + total: number; + userId: number; + }; + } +} +``` + + + Keep payloads JSON-serializable (no class instances, Maps, or functions). A + broker adapter serializes the envelope to move it between processes. Prefer + ids over whole records - listeners can load what they need. + + +Cross-plugin subscriptions just work: any plugin can listen to core's +`user.created` (or another plugin's events) by importing nothing more than +`buildEventListener`: + +```ts +export const welcomeListener = buildEventListener({ + event: "user.created", + name: "send-welcome-email", + handler: async (c, payload) => { + await c.get("queue").dispatch({ + name: "send-welcome-email", + payload: { userId: payload.userId, email: payload.email }, + }); + }, +}); +``` diff --git a/apps/docs/content/docs/dev/events/meta.json b/apps/docs/content/docs/dev/events/meta.json new file mode 100644 index 000000000..1d545a2a0 --- /dev/null +++ b/apps/docs/content/docs/dev/events/meta.json @@ -0,0 +1,4 @@ +{ + "title": "Events", + "pages": ["built-in-events", "...", "custom-adapter"] +} diff --git a/apps/docs/content/docs/dev/meta.json b/apps/docs/content/docs/dev/meta.json index f05d08abf..44b41ac8d 100644 --- a/apps/docs/content/docs/dev/meta.json +++ b/apps/docs/content/docs/dev/meta.json @@ -16,6 +16,7 @@ "working-with-users", "i18n", "search", + "events", "advanced", "---Adapters---", "captcha", diff --git a/packages/vitnode/scripts/prepare-plugins-files.ts b/packages/vitnode/scripts/prepare-plugins-files.ts index 74d759d9d..76e5d6eec 100644 --- a/packages/vitnode/scripts/prepare-plugins-files.ts +++ b/packages/vitnode/scripts/prepare-plugins-files.ts @@ -186,8 +186,11 @@ export const preparePluginsFiles = async (flag?: string) => { destinationDir: langDest, }, // Breadcrumb parallel-route slots ship as framework routes and copy - // into the `@breadcrumb` slot, namespaced per plugin under - // `(plugins)/()` like every other copied route. + // into the SHARED `@breadcrumb` slot (NOT namespaced under + // `(plugins)/()` like other routes): core and every plugin + // write their breadcrumb pages side by side into the same dir. Route + // sync must never mirror-cleanup this dir (see `cleanupDeletedFiles`) + // or one contributor's copy wipes another's. { sourceDir: join( pluginPath, diff --git a/packages/vitnode/scripts/shared/file-utils.test.ts b/packages/vitnode/scripts/shared/file-utils.test.ts index f0cad4a4d..dfcbede89 100644 --- a/packages/vitnode/scripts/shared/file-utils.test.ts +++ b/packages/vitnode/scripts/shared/file-utils.test.ts @@ -67,13 +67,14 @@ describe("cleanupDeletedFiles", () => { }); it("removes only orphaned dest files that no longer exist in an owned source", () => { - const sourceDir = join(root, "src", "routes", "breadcrumb", "main"); + const sourceDir = join(root, "src", "routes", "main"); const destinationDir = join( root, "app", "[locale]", "(main)", - "@breadcrumb", + "(plugins)", + "(blog)", ); // source ships only `page.tsx` @@ -92,6 +93,30 @@ describe("cleanupDeletedFiles", () => { expect(existsSync(stale)).toBe(false); }); + it("never prunes a shared @breadcrumb dir even when the source owns files", () => { + const sourceDir = join(root, "src", "routes", "breadcrumb", "main"); + const destinationDir = join( + root, + "app", + "[locale]", + "(main)", + "@breadcrumb", + ); + + write(join(sourceDir, "page.tsx")); + + const owned = join(destinationDir, "page.tsx"); + const foreign = join(destinationDir, "other", "page.tsx"); + write(owned); + write(foreign); + + cleanupDeletedFiles(sourceDir, destinationDir, remove); + + expect(removed).toEqual([]); + expect(existsSync(owned)).toBe(true); + expect(existsSync(foreign)).toBe(true); + }); + it("never prunes locale directories even with a mismatched source", () => { const sourceDir = join(root, "src", "locales"); const destinationDir = join(root, "app", "src", "locales", "@vitnode-core"); diff --git a/packages/vitnode/scripts/shared/file-utils.ts b/packages/vitnode/scripts/shared/file-utils.ts index a88763eaa..35c91cb2b 100644 --- a/packages/vitnode/scripts/shared/file-utils.ts +++ b/packages/vitnode/scripts/shared/file-utils.ts @@ -345,6 +345,11 @@ export const cleanupDeletedFiles = ( .includes("src/locales"); if (isLocaleDir) return; + const isBreadcrumbDir = destinationDir + .replace(/\\/g, "/") + .includes("/@breadcrumb"); + if (isBreadcrumbDir) return; + const destFiles = getAllFiles(destinationDir); for (const destFile of destFiles) { const relativePath = relative(destinationDir, destFile); diff --git a/packages/vitnode/src/api/adapters/events/local.ts b/packages/vitnode/src/api/adapters/events/local.ts new file mode 100644 index 000000000..aed210a5e --- /dev/null +++ b/packages/vitnode/src/api/adapters/events/local.ts @@ -0,0 +1,66 @@ +import type { Context } from "hono"; + +import type { EnvVitNode } from "@/api/middlewares/global.middleware"; +import type { + EventEmitResult, + EventEnvelope, + EventsApiPlugin, +} from "@/api/models/events"; + +/** + * In-process event delivery (default). Runs matching listeners sequentially, + * in registration order (core plugin first, then the app's `plugins` order), + * inside the emitting request. Single-process by design: listeners only run + * on the instance that emitted the event - swap the adapter for a broker to + * fan out across instances. + */ +export const LocalEventsAdapter = (): EventsApiPlugin => ({ + name: "local", + + publish: async ( + c: Context, + envelope: EventEnvelope, + ): Promise => { + const listeners = c + .get("core") + .events.listeners.filter(listener => listener.event === envelope.name); + + const failures: EventEmitResult["failures"] = []; + let delivered = 0; + + for (const listener of listeners) { + try { + await listener.handler( + c as Context, + envelope.payload, + envelope, + ); + delivered++; + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + failures.push({ + pluginId: listener.pluginId, + module: listener.module, + listener: listener.name, + error, + }); + const message = `Event listener "${listener.pluginId}:${listener.module}:${listener.name}" for "${envelope.name}" failed: ${error}`; + try { + await c.get("log").error(message); + } catch { + // eslint-disable-next-line no-console + console.error( + `[VitNode] Failed to log event listener failure: ${message}`, + ); + } + } + } + + return { + eventId: envelope.eventId, + status: "delivered", + delivered, + failures, + }; + }, +}); diff --git a/packages/vitnode/src/api/config.ts b/packages/vitnode/src/api/config.ts index b5cf20248..22dd0557e 100644 --- a/packages/vitnode/src/api/config.ts +++ b/packages/vitnode/src/api/config.ts @@ -83,6 +83,7 @@ export function VitNodeAPI({ dbProvider: vitNodeApiConfig.dbProvider, captcha: vitNodeApiConfig.captcha, cron: vitNodeApiConfig.cron, + events: vitNodeApiConfig.events, search: vitNodeApiConfig.search, storage: vitNodeApiConfig.storage, plugins: [newBuildPluginApiCore, ...vitNodeApiConfig.plugins], diff --git a/packages/vitnode/src/api/lib/events.ts b/packages/vitnode/src/api/lib/events.ts new file mode 100644 index 000000000..3192a9526 --- /dev/null +++ b/packages/vitnode/src/api/lib/events.ts @@ -0,0 +1,34 @@ +import type { Context } from "hono"; + +import type { EnvVitNode } from "../middlewares/global.middleware"; +import type { + EventEnvelope, + VitNodeEventName, + VitNodeEvents, +} from "../models/events"; + +export interface BuildEventListenerReturn< + K extends VitNodeEventName = VitNodeEventName, +> { + description?: string; + event: K; + handler: ( + c: Context, + payload: VitNodeEvents[K], + envelope: EventEnvelope, + ) => Promise | void; + name: string; +} + +export interface EventListenerConfig extends BuildEventListenerReturn { + module: string; + pluginId: string; +} + +export function buildEventListener( + args: BuildEventListenerReturn, +): BuildEventListenerReturn { + // Listeners are matched by `event` at dispatch time; the cast erases the + // per-event generic so listeners for different events can share one array. + return args as unknown as BuildEventListenerReturn; +} diff --git a/packages/vitnode/src/api/lib/module.ts b/packages/vitnode/src/api/lib/module.ts index 3eeb34cbd..1c8bcbf61 100644 --- a/packages/vitnode/src/api/lib/module.ts +++ b/packages/vitnode/src/api/lib/module.ts @@ -1,6 +1,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { BuildCronReturn } from "./cron"; +import type { BuildEventListenerReturn } from "./events"; import type { BuildQueueTaskReturn } from "./queue"; import type { Route } from "./route"; import type { BuildWebSocketReturn } from "./websocket"; @@ -16,6 +17,7 @@ export interface BaseBuildModuleReturn< Routes extends Route

[] = Route

[], > { cronJobs: BuildCronReturn[]; + events: BuildEventListenerReturn[]; hono: OpenAPIHono; modules?: BaseBuildModuleReturn

[]; name: M; @@ -45,10 +47,12 @@ export function buildModule< name, modules, cronJobs = [], + events = [], queueTasks = [], webSockets = [], }: { cronJobs?: BuildCronReturn[]; + events?: BuildEventListenerReturn[]; modules?: Modules; name: M; pluginId: P; @@ -77,6 +81,7 @@ export function buildModule< name, modules, cronJobs, + events, queueTasks, webSockets, }; diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts index 33a43274d..a6effde29 100644 --- a/packages/vitnode/src/api/lib/plugin.ts +++ b/packages/vitnode/src/api/lib/plugin.ts @@ -2,6 +2,7 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import type { SearchIndexer } from "../models/search"; import type { CronJobConfig } from "./cron"; +import type { EventListenerConfig } from "./events"; import type { BuildModuleReturn } from "./module"; import type { PermissionStaffConfig } from "./permission-staff"; import type { QueueTaskConfig } from "./queue"; @@ -11,6 +12,7 @@ import { checkPluginId } from "./check-plugin-id"; export interface BuildPluginApiReturn { cronJobs?: Omit[]; + events?: Omit[]; hono: OpenAPIHono; permissionStaff?: PermissionStaffConfig; pluginId: string; @@ -35,6 +37,7 @@ export function buildApiPlugin

({ const hono = new OpenAPIHono(); const cronJobs: BuildPluginApiReturn["cronJobs"] = []; + const events: BuildPluginApiReturn["events"] = []; const queueTasks: BuildPluginApiReturn["queueTasks"] = []; const webSockets: BuildPluginApiReturn["webSockets"] = []; modules.forEach(handler => { @@ -44,6 +47,10 @@ export function buildApiPlugin

({ cronJobs.push({ ...cron, module: handler.name }); }); + handler.events?.forEach(listener => { + events.push({ ...listener, module: handler.name }); + }); + handler.queueTasks?.forEach(task => { queueTasks.push({ ...task, module: handler.name }); }); @@ -57,6 +64,7 @@ export function buildApiPlugin

({ pluginId, hono, cronJobs, + events, queueTasks, searchIndexers, webSockets, diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts index 26c662e0d..ea68e6df0 100644 --- a/packages/vitnode/src/api/middlewares/global.middleware.ts +++ b/packages/vitnode/src/api/middlewares/global.middleware.ts @@ -6,9 +6,11 @@ import { HTTPException } from "hono/http-exception"; import type { VitNodeApiConfig, VitNodeConfig } from "@/vitnode.config"; import type { VitNodeRealtime } from "@/ws/registry"; +import { LocalEventsAdapter } from "@/api/adapters/events/local"; import { PostgresSearchAdapter } from "@/api/adapters/search/postgres"; import { CacheModel } from "@/api/lib/cache"; import { EmailModel } from "@/api/models/email"; +import { EventsModel } from "@/api/models/events"; import { QueueModel } from "@/api/models/queue"; import { SearchModel } from "@/api/models/search"; import { SessionModel } from "@/api/models/session"; @@ -18,9 +20,11 @@ import { CONFIG } from "@/lib/config"; import { realtime } from "@/ws/registry"; import type { BuildCronReturn } from "../lib/cron"; +import type { EventListenerConfig } from "../lib/events"; import type { PermissionStaffCatalogEntry } from "../lib/permission-staff"; import type { BuildQueueTaskReturn } from "../lib/queue"; import type { WebSocketConfig } from "../lib/websocket"; +import type { EventsApiPlugin } from "../models/events"; import type { SearchIndexerConfig, SearchProviderApiPlugin, @@ -74,6 +78,7 @@ export interface EnvVariablesVitNode { cron: (BuildCronReturn & { module: string; pluginId: string })[]; cronSecret?: string; email?: VitNodeApiConfig["email"]; + events: { adapter: EventsApiPlugin; listeners: EventListenerConfig[] }; // Whether a cron adapter is configured (`buildApiConfig({ cron })`), i.e. an // in-process scheduler is running the registered jobs automatically. Without // it, jobs only run when the cron endpoint is triggered externally. @@ -93,6 +98,7 @@ export interface EnvVariablesVitNode { }; db: Pick["dbProvider"]; email: EmailModel; + events: EventsModel; ipAddress: string; log: LoggerMiddlewareType; plugin: { @@ -123,6 +129,7 @@ export const globalMiddleware = ({ dbProvider, captcha, cron, + events, plugins, pathToMessages, search, @@ -135,6 +142,7 @@ export const globalMiddleware = ({ | "cron" | "dbProvider" | "email" + | "events" | "pathToMessages" | "plugins" | "search" @@ -147,6 +155,13 @@ export const globalMiddleware = ({ const cronMetadata = collectCronJobs(plugins); + const eventsMetadata: EventListenerConfig[] = plugins.flatMap(plugin => + (plugin.events ?? []).map(listener => ({ + ...listener, + pluginId: plugin.pluginId, + })), + ); + const queueMetadata = plugins.flatMap(plugin => (plugin.queueTasks ?? []).map(task => ({ pluginId: plugin.pluginId, @@ -219,6 +234,7 @@ export const globalMiddleware = ({ c.set("db", dbProvider); c.set("cache", new CacheModel(cacheClient, c)); c.set("email", new EmailModel(c)); + c.set("events", new EventsModel(c)); c.set("queue", new QueueModel(c)); c.set("search", new SearchModel(c)); c.set("storage", new StorageModel(c)); @@ -228,6 +244,10 @@ export const globalMiddleware = ({ pathToMessages, metadata, email, + events: { + adapter: events?.adapter ?? LocalEventsAdapter(), + listeners: eventsMetadata, + }, search: { adapter: search?.adapter ?? PostgresSearchAdapter() }, searchIndexers: searchIndexersMetadata, storage, diff --git a/packages/vitnode/src/api/models/events.test.ts b/packages/vitnode/src/api/models/events.test.ts new file mode 100644 index 000000000..f9b834a32 --- /dev/null +++ b/packages/vitnode/src/api/models/events.test.ts @@ -0,0 +1,263 @@ +// @vitest-environment node +import type { Context } from "hono"; + +import { describe, expect, it, vi } from "vitest"; + +import type { EventListenerConfig } from "../lib/events"; +import type { EventsApiPlugin } from "./events"; + +import { LocalEventsAdapter } from "../adapters/events/local"; +import { EventsModel } from "./events"; + +const makeCtx = ( + overrides: { + adapter?: EventsApiPlugin; + admin?: { user: { id: number } }; + listeners?: EventListenerConfig[]; + plugin?: { id: string }; + user?: { id: number }; + } = {}, +): { + ctx: Context; + logError: ReturnType; +} => { + const logError = vi.fn().mockResolvedValue(undefined); + const store: Record = { + admin: overrides.admin ?? null, + core: { + events: { + adapter: overrides.adapter ?? LocalEventsAdapter(), + listeners: overrides.listeners ?? [], + }, + }, + log: { error: logError }, + plugin: overrides.plugin, + user: overrides.user ?? null, + }; + + return { + ctx: { get: (k: string) => store[k] } as unknown as Context, + logError, + }; +}; + +const makeListener = ( + overrides: Partial = {}, +): EventListenerConfig => ({ + event: "user.created", + name: "listener", + module: "users", + pluginId: "@vitnode/core", + handler: vi.fn().mockResolvedValue(undefined), + ...overrides, +}); + +const PAYLOAD = { + userId: 1, + email: "a@b.com", + name: "Test", + emailVerified: true, +}; + +describe("EventsModel.emit (Local adapter)", () => { + it("runs only listeners matching the event, sequentially in registry order", async () => { + const order: string[] = []; + const first = makeListener({ + name: "first", + handler: async () => { + // Resolve on a later tick so a concurrent dispatch would flip the order. + await new Promise(resolve => setTimeout(resolve, 5)); + order.push("first"); + }, + }); + const second = makeListener({ + name: "second", + handler: () => { + order.push("second"); + }, + }); + const other = makeListener({ + name: "other", + event: "other.event" as EventListenerConfig["event"], + handler: () => { + order.push("other"); + }, + }); + const { ctx } = makeCtx({ listeners: [first, other, second] }); + + const result = await new EventsModel(ctx).emit("user.created", PAYLOAD); + + expect(order).toEqual(["first", "second"]); + expect(result.delivered).toBe(2); + expect(result.failures).toEqual([]); + }); + + it("passes payload and envelope to the handler", async () => { + const handler = vi.fn().mockResolvedValue(undefined); + const { ctx } = makeCtx({ listeners: [makeListener({ handler })] }); + + await new EventsModel(ctx).emit("user.created", PAYLOAD); + + expect(handler).toHaveBeenCalledWith( + ctx, + PAYLOAD, + expect.objectContaining({ name: "user.created", payload: PAYLOAD }), + ); + }); + + it("a throwing listener does not stop the remaining listeners", async () => { + const ran: string[] = []; + const failing = makeListener({ + name: "failing", + module: "posts", + pluginId: "@vitnode/blog", + handler: () => { + throw new Error("boom"); + }, + }); + const after = makeListener({ + name: "after", + handler: () => { + ran.push("after"); + }, + }); + const { ctx, logError } = makeCtx({ listeners: [failing, after] }); + + const result = await new EventsModel(ctx).emit("user.created", PAYLOAD); + + expect(ran).toEqual(["after"]); + expect(result.delivered).toBe(1); + expect(result.failures).toEqual([ + { + pluginId: "@vitnode/blog", + module: "posts", + listener: "failing", + error: "boom", + }, + ]); + expect(logError).toHaveBeenCalledTimes(1); + expect(logError.mock.calls[0][0]).toContain( + '"@vitnode/blog:posts:failing"', + ); + }); + + it("resolves even when every listener throws", async () => { + const listeners = [ + makeListener({ + name: "a", + handler: () => { + throw new Error("a failed"); + }, + }), + makeListener({ + name: "b", + handler: async () => Promise.reject(new Error("b failed")), + }), + ]; + const { ctx } = makeCtx({ listeners }); + + const result = await new EventsModel(ctx).emit("user.created", PAYLOAD); + + expect(result.delivered).toBe(0); + expect(result.failures).toHaveLength(2); + expect(result.status).toBe("delivered"); + }); + + it("returns an empty result when no listeners match", async () => { + const { ctx } = makeCtx(); + + const result = await new EventsModel(ctx).emit("user.created", PAYLOAD); + + expect(result).toMatchObject({ + delivered: 0, + failures: [], + status: "delivered", + }); + expect(result.eventId).toMatch(/^[0-9a-f-]{36}$/); + }); +}); + +describe("EventsModel.emit envelope", () => { + const captureEnvelope = () => { + const publish = vi.fn().mockResolvedValue({ + eventId: "x", + status: "delivered", + delivered: 0, + failures: [], + }); + + return { adapter: { name: "capture", publish }, publish }; + }; + + it("stamps eventId, emittedAt and defaults pluginId to @vitnode/core", async () => { + const { adapter, publish } = captureEnvelope(); + const { ctx } = makeCtx({ adapter }); + + await new EventsModel(ctx).emit("user.created", PAYLOAD); + + const envelope = publish.mock.calls[0][1]; + expect(envelope.eventId).toMatch(/^[0-9a-f-]{36}$/); + expect(envelope.emittedAt).toBeInstanceOf(Date); + expect(envelope.pluginId).toBe("@vitnode/core"); + }); + + it("uses the emitting plugin id from context", async () => { + const { adapter, publish } = captureEnvelope(); + const { ctx } = makeCtx({ adapter, plugin: { id: "@vitnode/blog" } }); + + await new EventsModel(ctx).emit("user.created", PAYLOAD); + + expect(publish.mock.calls[0][1].pluginId).toBe("@vitnode/blog"); + }); + + it("derives the actor: admin wins over user, then user, then system", async () => { + const { adapter, publish } = captureEnvelope(); + + const { ctx: adminCtx } = makeCtx({ + adapter, + admin: { user: { id: 7 } }, + user: { id: 3 }, + }); + await new EventsModel(adminCtx).emit("user.created", PAYLOAD); + expect(publish.mock.calls[0][1].actor).toEqual({ type: "admin", id: 7 }); + + const { ctx: userCtx } = makeCtx({ adapter, user: { id: 3 } }); + await new EventsModel(userCtx).emit("user.created", PAYLOAD); + expect(publish.mock.calls[1][1].actor).toEqual({ type: "user", id: 3 }); + + const { ctx: systemCtx } = makeCtx({ adapter }); + await new EventsModel(systemCtx).emit("user.created", PAYLOAD); + expect(publish.mock.calls[2][1].actor).toEqual({ type: "system" }); + }); +}); + +describe("EventsModel adapter seam", () => { + it("delegates to the configured adapter and passes its result through", async () => { + const publish = vi.fn().mockResolvedValue({ + eventId: "broker-id", + status: "queued", + delivered: 0, + failures: [], + }); + const { ctx } = makeCtx({ adapter: { name: "broker", publish } }); + + const result = await new EventsModel(ctx).emit("user.created", PAYLOAD); + + expect(publish).toHaveBeenCalledTimes(1); + expect(result.status).toBe("queued"); + }); + + it("never throws when the adapter itself fails; logs and reports it", async () => { + const publish = vi.fn().mockRejectedValue(new Error("broker down")); + const { ctx, logError } = makeCtx({ + adapter: { name: "broker", publish }, + }); + + const result = await new EventsModel(ctx).emit("user.created", PAYLOAD); + + expect(result.delivered).toBe(0); + expect(result.failures).toHaveLength(1); + expect(result.failures[0].error).toBe("broker down"); + expect(logError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/vitnode/src/api/models/events.ts b/packages/vitnode/src/api/models/events.ts new file mode 100644 index 000000000..39eb12873 --- /dev/null +++ b/packages/vitnode/src/api/models/events.ts @@ -0,0 +1,176 @@ +import type { Context } from "hono"; + +import { randomUUID } from "node:crypto"; + +import type { EventListenerConfig } from "../lib/events"; + +/** + * Global map of domain events emittable via `c.get("events").emit(...)`. Core + * events are declared here; plugins extend the map with module augmentation: + * + * ```ts + * declare module "@vitnode/core/api/models/events" { + * interface VitNodeEvents { + * "blog.post.created": { categoryId: number; postId: number }; + * } + * } + * ``` + * + * Payloads must stay JSON-serializable - a broker adapter (Redis Streams, + * NATS, ...) serializes the envelope to move it between processes. + */ +export interface VitNodeEvents { + "role.created": { + roleId: number; + }; + /** + * Declared for plugins implementing role deletion - core has no role + * deletion flow yet and never emits this itself. + */ + "role.deleted": { + roleId: number; + }; + "role.updated": { + roleId: number; + }; + "user.created": { + email: string; + emailVerified: boolean; + name: string; + userId: number; + }; + /** + * Declared for plugins implementing account deletion - core has no user + * deletion flow yet and never emits this itself. + */ + "user.deleted": { + email: string; + userId: number; + }; + "user.updated": { + email: string; + name: string; + userId: number; + }; +} + +export type VitNodeEventName = keyof VitNodeEvents; + +export interface EventActor { + id?: number; + type: "admin" | "system" | "user"; +} + +export interface EventEnvelope { + actor: EventActor; + emittedAt: Date; + eventId: string; + name: K; + payload: VitNodeEvents[K]; + /** Plugin that emitted the event. */ + pluginId: string; +} + +export interface EventEmitFailure { + error: string; + /** Listener `name` as declared in `buildEventListener`. */ + listener: string; + module: string; + /** Plugin that owns the failing listener. */ + pluginId: string; +} + +export interface EventEmitResult { + /** Listeners that ran successfully before `emit()` resolved. */ + delivered: number; + eventId: string; + failures: EventEmitFailure[]; + /** + * `delivered` - listeners ran in-process before `emit()` resolved (the + * bundled Local adapter). `queued` - the envelope was handed to a broker and + * delivery happens out-of-band; `delivered`/`failures` say nothing about the + * eventual listener runs. + */ + status: "delivered" | "queued"; +} + +/** + * A pluggable event transport. The bundled Local adapter dispatches directly + * to the listeners registered in `c.get("core").events.listeners`; a broker + * adapter publishes the envelope and returns `status: "queued"`. + */ +export interface EventsApiPlugin { + name: string; + publish: (c: Context, envelope: EventEnvelope) => Promise; +} + +export class EventsModel { + constructor(c: Context) { + this.c = c; + } + + protected readonly c: Context; + + private adapter(): EventsApiPlugin { + return this.c.get("core").events.adapter; + } + + /** + * Emit a typed domain event. Never throws: listener failures are caught, + * logged to `core_logs`, and reported in the returned result. Emit only + * AFTER the writes the event describes have committed - after your awaited + * inserts/updates, and after any enclosing `db.transaction` callback has + * returned. + */ + async emit( + name: K, + payload: VitNodeEvents[K], + ): Promise { + const admin = this.c.get("admin"); + const user = this.c.get("user"); + const envelope: EventEnvelope = { + eventId: randomUUID(), + name, + payload, + emittedAt: new Date(), + pluginId: this.c.get("plugin")?.id ?? "@vitnode/core", + actor: admin + ? { type: "admin", id: admin.user.id } + : user + ? { type: "user", id: user.id } + : { type: "system" }, + }; + const adapter = this.adapter(); + + try { + return await adapter.publish(this.c, envelope); + } catch (err) { + const error = err instanceof Error ? err.message : String(err); + await this.c + .get("log") + .error( + `Events adapter "${adapter.name}" failed to publish "${name}": ${error}`, + ); + + return { + eventId: envelope.eventId, + status: "delivered", + delivered: 0, + failures: [ + { + pluginId: envelope.pluginId, + module: "adapter", + listener: adapter.name, + error, + }, + ], + }; + } + } + + name(): string { + return this.adapter().name; + } +} + +export type { EventListenerConfig }; diff --git a/packages/vitnode/src/api/models/user/sign-up.ts b/packages/vitnode/src/api/models/user/sign-up.ts index 36f47f146..82f19e6c6 100644 --- a/packages/vitnode/src/api/models/user/sign-up.ts +++ b/packages/vitnode/src/api/models/user/sign-up.ts @@ -130,5 +130,16 @@ export const signUp = async ( // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password: _, ...user } = data; + // The insert above runs on `c.get("db")` (auto-commit), so the row is + // committed here - even when a caller (e.g. the SSO callback) wraps this in + // `db.transaction`. If this insert ever moves onto a `tx` handle, the emit + // must move after that transaction returns. + await c.get("events").emit("user.created", { + userId: data.id, + email: data.email, + name: data.name, + emailVerified: data.emailVerified, + }); + return user; }; diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts index ca7b4a77c..3a1dcd468 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts @@ -68,6 +68,8 @@ export const createRoleAdminRoute = buildRoute({ values: name, }); + await c.get("events").emit("role.created", { roleId: role.id }); + return c.json({ id: role.id }, 201); }, }); diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts index f7293838f..456da7bb5 100644 --- a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts @@ -98,6 +98,8 @@ export const updateRoleAdminRoute = buildRoute({ }); } + await c.get("events").emit("role.updated", { roleId }); + return c.json({ id: roleId }, 200); }, }); diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts index f6ce210ac..df6ddc8a3 100644 --- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts +++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts @@ -238,9 +238,16 @@ export const updateUserAdminRoute = buildRoute({ nameCode: core_users.nameCode, }); + await c.get("events").emit("user.updated", { + userId: updated.id, + email: updated.email, + name: updated.name, + }); + return c.json(updated, 200); } + // No column change, but roles may still have been reassigned above. const [current] = await db .select({ id: core_users.id, @@ -252,6 +259,12 @@ export const updateUserAdminRoute = buildRoute({ .where(eq(core_users.id, user.id)) .limit(1); + await c.get("events").emit("user.updated", { + userId: current.id, + email: current.email, + name: current.name, + }); + return c.json(current, 200); }, }); diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index 9bbc02a69..195661ffc 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -7,6 +7,7 @@ import type React from "react"; import type { CronAdapter } from "./api/lib/cron"; import type { BuildPluginApiReturn } from "./api/lib/plugin"; import type { EmailApiPlugin } from "./api/models/email"; +import type { EventsApiPlugin } from "./api/models/events"; import type { SearchProviderApiPlugin } from "./api/models/search"; import type { SSOApiPlugin } from "./api/models/sso"; import type { StorageApiPlugin } from "./api/models/storage"; @@ -61,6 +62,16 @@ export interface VitNodeApiConfig { logo?: DefaultTemplateEmailProps["templateProps"]["logo"]; tailwindConfig?: DefaultTemplateEmailProps["templateProps"]["tailwindConfig"]; }; + /** + * Transport for domain events emitted via `c.get("events").emit(...)`. Ships + * a zero-config Local adapter used when `adapter` is omitted: listeners run + * sequentially in the emitting request, on the emitting instance only + * (single-process delivery). Swap the adapter to publish events to an + * external broker (e.g. Redis Streams, NATS) for cross-instance delivery. + */ + events?: { + adapter?: EventsApiPlugin; + }; metadata: { shortTitle?: string; title: string; diff --git a/plugins/blog/src/api/lib/events.ts b/plugins/blog/src/api/lib/events.ts new file mode 100644 index 000000000..b9932c9e8 --- /dev/null +++ b/plugins/blog/src/api/lib/events.ts @@ -0,0 +1,40 @@ +import { buildEventListener } from "@vitnode/core/api/lib/events"; + +declare module "@vitnode/core/api/models/events" { + interface VitNodeEvents { + "blog.category.created": { + categoryId: number; + }; + "blog.category.deleted": { + categoryId: number; + postIds: number[]; + }; + "blog.category.updated": { + categoryId: number; + }; + "blog.post.created": { + categoryId: number; + postId: number; + }; + "blog.post.deleted": { + categoryId: number; + postId: number; + }; + "blog.post.updated": { + categoryId: number; + postId: number; + }; + } +} + +export const cleanupCategorySearchListener = buildEventListener({ + event: "blog.category.deleted", + name: "cleanup-category-search", + description: + "Remove search index rows of posts cascade-deleted with a category", + handler: async (c, payload) => { + for (const postId of payload.postIds) { + await c.get("search").delete("blog_post", postId); + } + }, +}); diff --git a/plugins/blog/src/api/modules/admin/admin.module.ts b/plugins/blog/src/api/modules/admin/admin.module.ts index 86aa8b2de..4113aca19 100644 --- a/plugins/blog/src/api/modules/admin/admin.module.ts +++ b/plugins/blog/src/api/modules/admin/admin.module.ts @@ -1,6 +1,7 @@ import { buildModule } from "@vitnode/core/api/lib/module"; import { CONFIG_PLUGIN } from "../../../const"; +import { cleanupCategorySearchListener } from "../../lib/events"; import { categoriesAdminModule } from "./categories/categories.admin.module"; import { postsAdminModule } from "./posts/posts.admin.module"; @@ -9,4 +10,8 @@ export const adminModule = buildModule({ name: "admin", modules: [categoriesAdminModule, postsAdminModule], routes: [], + // Event listeners are only collected from top-level modules (like cronJobs + // and queueTasks), so they are registered here rather than on the nested + // categories module. + events: [cleanupCategorySearchListener], }); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts index bc0dba2e6..7f452cf0b 100644 --- a/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts +++ b/plugins/blog/src/api/modules/admin/categories/routes/create.route.ts @@ -59,6 +59,10 @@ export const createCategoryRoute = buildRoute({ await saveCategoryTranslations(c, category.id, { title }); + await c.get("events").emit("blog.category.created", { + categoryId: category.id, + }); + return c.json(category, 201); }, }); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts index 82d5d3c28..8daa567e5 100644 --- a/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts +++ b/plugins/blog/src/api/modules/admin/categories/routes/delete.route.ts @@ -5,6 +5,7 @@ import { HTTPException } from "hono/http-exception"; import { CONFIG_PLUGIN } from "@/const"; import { blog_categories } from "@/database/categories"; +import { blog_posts } from "@/database/posts"; export const deleteCategoryRoute = buildRoute({ pluginId: CONFIG_PLUGIN.pluginId, @@ -29,6 +30,14 @@ export const deleteCategoryRoute = buildRoute({ handler: async c => { const { id } = c.req.valid("param"); + // Capture the posts the category's `onDelete: "cascade"` is about to + // remove, so listeners (e.g. search index cleanup) know what went away. + const posts = await c + .get("db") + .select({ id: blog_posts.id }) + .from(blog_posts) + .where(eq(blog_posts.categoryId, id)); + const result = await c .get("db") .delete(blog_categories) @@ -39,6 +48,11 @@ export const deleteCategoryRoute = buildRoute({ throw new HTTPException(404); } + await c.get("events").emit("blog.category.deleted", { + categoryId: id, + postIds: posts.map(post => post.id), + }); + return c.body(null, 204); }, }); diff --git a/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts b/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts index 47cf8a99e..8b1228269 100644 --- a/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts +++ b/plugins/blog/src/api/modules/admin/categories/routes/edit.route.ts @@ -75,6 +75,10 @@ export const editCategoryRoute = buildRoute({ await saveCategoryTranslations(c, id, { title }); + await c.get("events").emit("blog.category.updated", { + categoryId: id, + }); + return c.json(category); }, }); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts index ef63b2e6e..c6321ca04 100644 --- a/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts +++ b/plugins/blog/src/api/modules/admin/posts/routes/create.route.ts @@ -123,6 +123,11 @@ export const createPostRoute = buildRoute({ await savePostTranslations(c, post.id, { title, content, friendlyUrl }); await reindexBlogPost(c, post); + await c.get("events").emit("blog.post.created", { + postId: post.id, + categoryId: post.categoryId, + }); + return c.json(post, 201); }, }); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts index 3757c731f..6337e7c73 100644 --- a/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts +++ b/plugins/blog/src/api/modules/admin/posts/routes/delete.route.ts @@ -41,6 +41,11 @@ export const deletePostRoute = buildRoute({ await c.get("search").delete("blog_post", id); + await c.get("events").emit("blog.post.deleted", { + postId: id, + categoryId: result[0].categoryId, + }); + return c.body(null, 204); }, }); diff --git a/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts b/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts index 67a746a26..f3d80dc6d 100644 --- a/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts +++ b/plugins/blog/src/api/modules/admin/posts/routes/edit.route.ts @@ -129,6 +129,11 @@ export const editPostRoute = buildRoute({ await savePostTranslations(c, id, { title, content, friendlyUrl }); await reindexBlogPost(c, post); + await c.get("events").emit("blog.post.updated", { + postId: post.id, + categoryId: post.categoryId, + }); + return c.json(post); }, });