From 86fa6211742d186c24390fb430b534510283250f Mon Sep 17 00:00:00 2001 From: canghai118 <3394863+canghai118@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:28:52 +0000 Subject: [PATCH 1/2] feat: add domain events with per-fact nitro hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Business code announces a committed fact once, and anything that should follow it — notifications, derived data, integrations — subscribes instead of being wired into the endpoint that happened to trigger it. A deployment can register its own listener from a Nitro plugin without forking core code. Delivery is best-effort and off the response path: a listener never delays or fails the business response, and one listener's error cannot reach another. --- server/api/posts/index.post.ts | 25 +-- server/api/widget/messages.post.ts | 69 +++++--- server/plugins/feedback-listeners.ts | 64 +++++++ server/utils/domain-events.ts | 240 +++++++++++++++++++++++++++ 4 files changed, 358 insertions(+), 40 deletions(-) create mode 100644 server/plugins/feedback-listeners.ts create mode 100644 server/utils/domain-events.ts diff --git a/server/api/posts/index.post.ts b/server/api/posts/index.post.ts index 85b0892..92fe316 100644 --- a/server/api/posts/index.post.ts +++ b/server/api/posts/index.post.ts @@ -1,4 +1,3 @@ -import { getRequestURL } from 'h3' import { createPostSchema } from '#layers/feedlog/shared/schemas/post' import { isActorAdmin } from '#layers/feedlog/shared/utils/notifications' @@ -18,24 +17,12 @@ export default defineEventHandler(async (event) => { subscribeAuthor: !isActorAdmin(session, orgId), }) - // Async embedding generation (non-blocking) - event.waitUntil( - generatePostEmbedding(created.id, orgId, body.title, body.content, created.contentHash), - ) - - if (!isActorAdmin(session, orgId)) { - event.waitUntil( - emitAdminNotification({ - orgId, - typeKey: 'post.created', - postSlug: created.slug, - postTitle: created.title, - snippet: body.content, - actorId: session.user.id, - requestOrigin: getRequestURL(event).origin, - }).catch((err: unknown) => console.error('[notifications] post created emit failed', err)), - ) - } + publishDomainEvent(event, createDomainEvent({ + name: 'feedback.created', + orgId, + userId: session.user.id, + data: { feedbackId: created.id, boardId: created.boardId, source: 'portal', messageId: null }, + })) const author = await fetchPostAuthor(session.user.id) diff --git a/server/api/widget/messages.post.ts b/server/api/widget/messages.post.ts index 9536771..46a7779 100644 --- a/server/api/widget/messages.post.ts +++ b/server/api/widget/messages.post.ts @@ -1,6 +1,5 @@ import OpenAI from 'openai' import { asc, eq } from 'drizzle-orm' -import { getRequestURL } from 'h3' import { board, conversation, message, organizationWidget } from '#layers/feedlog/server/db/schemas' import { buildWidgetSystemPrompt, historyToMessages, parseWidgetAiResponse, parseWidgetHistory } from '#layers/feedlog/server/utils/widget-ai' import { CONVERSATION_TOKEN_BUDGET, estimateTokens, isConversationId, ownedConversation } from '#layers/feedlog/server/utils/conversation' @@ -136,7 +135,7 @@ export default defineEventHandler(async (event): Promise // Stored before the model is asked anything: a failed call then leaves an // unanswered message, where writing afterwards would lose what they typed. const sentAt = new Date() - const conversationId = await db.transaction(async (tx) => { + const { conversationId, userMessageId } = await db.transaction(async (tx) => { let id = requestedId if (id) { await tx.update(conversation) @@ -149,13 +148,31 @@ export default defineEventHandler(async (event): Promise .returning({ id: conversation.id }) id = row!.id } - await tx.insert(message).values({ conversationId: id, role: 'user', text, images }) - return id + const [userMessage] = await tx.insert(message) + .values({ conversationId: id, role: 'user', text, images }) + .returning({ id: message.id }) + return { conversationId: id, userMessageId: userMessage!.id } }) + // Published only now that the transaction has committed: an event about a + // rolled-back row would tell listeners of a fact that does not exist. The + // user message id ties together every event of this message's flow. + publishDomainEvent(event, createDomainEvent({ + name: 'widget.message-received', + orgId, + userId, + data: { + conversationId, + messageId: userMessageId, + isNewConversation: !requestedId, + attachmentCount: images.length, + }, + })) + // Images are attached to the post but never sent to the model: extraction is // text-only for now, so a screenshot-only message is unrecognized by design. let parsed: WidgetAiOutput | null = null + let resolutionSource: 'model' | 'policy-fallback' = 'model' try { const client = new OpenAI({ apiKey, baseURL }) const resp = await client.chat.completions.create({ @@ -180,8 +197,15 @@ export default defineEventHandler(async (event): Promise const code = (err as { code?: string })?.code if (status === 400 && code === 'content_filter') { parsed = { type: 'unrecognized' } + resolutionSource = 'policy-fallback' } else { + publishDomainEvent(event, createDomainEvent({ + name: 'widget.message-processing-failed', + orgId, + userId, + data: { conversationId, messageId: userMessageId, reason: 'provider-error' }, + })) const detail = err instanceof Error ? err.message : 'Unknown AI error' throw createError({ statusCode: 502, message: `AI extraction failed: ${detail}` }) } @@ -189,6 +213,12 @@ export default defineEventHandler(async (event): Promise // A malformed response is a transient model failure — the SDK may retry. if (!parsed) { + publishDomainEvent(event, createDomainEvent({ + name: 'widget.message-processing-failed', + orgId, + userId, + data: { conversationId, messageId: userMessageId, reason: 'invalid-output' }, + })) throw createError({ statusCode: 502, message: 'AI returned an unusable response' }) } const ai = parsed @@ -253,24 +283,21 @@ export default defineEventHandler(async (event): Promise return post }) - // Both of these read committed rows, so they follow the transaction. + // Published after the transaction resolves, never inside it — the assistant + // row (and the post, when there is one) must exist before listeners are told. + publishDomainEvent(event, createDomainEvent({ + name: 'widget.message-resolved', + orgId, + userId, + data: { conversationId, messageId: userMessageId, outcome: ai.type, resolutionSource }, + })) if (created) { - event.waitUntil( - generatePostEmbedding(created.id, orgId, created.title, content, created.contentHash), - ) - if (!isActorAdmin(session, orgId)) { - event.waitUntil( - emitAdminNotification({ - orgId, - typeKey: 'post.created', - postSlug: created.slug, - postTitle: created.title, - snippet: content, - actorId: userId, - requestOrigin: getRequestURL(event).origin, - }).catch((err: unknown) => console.error('[notifications] widget post created emit failed', err)), - ) - } + publishDomainEvent(event, createDomainEvent({ + name: 'feedback.created', + orgId, + userId, + data: { feedbackId: created.id, boardId: created.boardId, source: 'widget', messageId: userMessageId }, + })) } return { diff --git a/server/plugins/feedback-listeners.ts b/server/plugins/feedback-listeners.ts new file mode 100644 index 0000000..53615d6 --- /dev/null +++ b/server/plugins/feedback-listeners.ts @@ -0,0 +1,64 @@ +import { and, eq } from 'drizzle-orm' +import { member, post } from '../db/schemas' + +// First in-process subscribers to feedback.created: embedding generation and +// the staff notification. As listeners they follow every entry point that +// publishes the event and stay off the response path. Each loads what it +// needs by id — the event payload is deliberately minimal — and fails +// independently: the dispatcher contains a listener error without touching +// the other listener or the business response. + +async function loadFeedbackPost(feedbackId: string) { + const [row] = await useDB() + .select({ + id: post.id, + slug: post.slug, + title: post.title, + content: post.content, + contentHash: post.contentHash, + }) + .from(post) + .where(eq(post.id, feedbackId)) + .limit(1) + return row ?? null +} + +// The create endpoints read "actor is staff" off the session's org list; a +// listener has no session, so it asks the member table — the source that org +// list mirrors. +async function actorIsOrgAdmin(orgId: string, userId: string): Promise { + const [row] = await useDB() + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, orgId), eq(member.userId, userId))) + .limit(1) + return row?.role === 'owner' || row?.role === 'manager' +} + +export default defineNitroPlugin((nitroApp) => { + onDomainEvent(nitroApp, 'feedback.created', async (domainEvent) => { + const row = await loadFeedbackPost(domainEvent.data.feedbackId) + // No row: deleted between commit and listener run. No hash: a legacy row + // this event cannot describe — every path that publishes writes one. + if (!row || !row.contentHash) return + await generatePostEmbedding(row.id, domainEvent.orgId, row.title, row.content, row.contentHash) + }) + + onDomainEvent(nitroApp, 'feedback.created', async (domainEvent, context) => { + // No user behind the event (system-initiated) — nobody to attribute, and + // staff filing feedback is routine work, not something to alert staff about. + if (!domainEvent.userId) return + if (await actorIsOrgAdmin(domainEvent.orgId, domainEvent.userId)) return + const row = await loadFeedbackPost(domainEvent.data.feedbackId) + if (!row) return + await emitAdminNotification({ + orgId: domainEvent.orgId, + typeKey: 'post.created', + postSlug: row.slug, + postTitle: row.title, + snippet: row.content, + actorId: domainEvent.userId, + requestOrigin: context?.requestOrigin, + }) + }) +}) diff --git a/server/utils/domain-events.ts b/server/utils/domain-events.ts new file mode 100644 index 0000000..4b3f23a --- /dev/null +++ b/server/utils/domain-events.ts @@ -0,0 +1,240 @@ +import type { H3Event } from 'h3' +import type { NitroApp } from 'nitropack/types' +import { getRequestURL } from 'h3' +import { uuidv7 } from 'uuidv7' + +// Domain events — how business code announces a fact that has already been +// committed (a feedback post exists, a widget message was accepted) to +// in-process subscribers: notifications, derived data such as embeddings, +// integrations, and listeners a self-hosted deployment registers from its own +// Nitro plugin without forking core code. +// +// Delivery is best-effort: listeners run outside the response lifecycle, a +// listener failure never affects the business response, and an event can be +// lost on process death. Anything that must not be lost needs a persistent +// queue, not a listener here. +// +// Business code must publish through this facade only — never call +// nitroApp.hooks.callHook or invent hook names. The facade is what keeps hook +// naming, failure isolation, and scheduling in one place. + +// The catalog: every publishable fact and its fields. `data` stays minimal — +// subscribers load anything else by id. The three widget message events and a +// widget-born feedback.created share a `messageId` (the triggering user +// message, written in the flow's first transaction) so subscribers can join +// the events of one message flow. +export interface FeedLogDomainEventMap { + 'widget.message-received': { + conversationId: string + messageId: string + isNewConversation: boolean + attachmentCount: number + } + 'widget.message-resolved': { + conversationId: string + messageId: string + outcome: 'feedback' | 'support' | 'clarify' | 'unrecognized' + resolutionSource: 'model' | 'policy-fallback' + } + 'widget.message-processing-failed': { + conversationId: string + messageId: string + reason: 'provider-error' | 'invalid-output' + } + 'feedback.created': { + feedbackId: string + boardId: string | null + // Which entry point filed the feedback — a domain fact in its own right + // (the post row does not record it). messageId is null for portal. + source: 'portal' | 'widget' + messageId: string | null + } +} + +export type DomainEventName = keyof FeedLogDomainEventMap + +// Interfaces have no runtime form, so the catalog names are repeated here for +// onAnyDomainEvent to register on; `satisfies` keeps the two in lockstep — +// a map entry missing here would silently escape cross-cutting subscribers. +const DOMAIN_EVENT_NAMES = Object.keys({ + 'widget.message-received': null, + 'widget.message-resolved': null, + 'widget.message-processing-failed': null, + 'feedback.created': null, +} satisfies Record) as DomainEventName[] + +export interface DomainEventEnvelope { + // Unique per event, so a subscriber's own logs can be tied back to the + // publishing flow. + id: string + name: Name + // When the business fact completed — not when a listener ran. + occurredAt: string + orgId: string + // The acting user; null for system-initiated flows. Guests count too — they + // hold a real server-side user id. + userId: string | null + data: FeedLogDomainEventMap[Name] +} + +// The union distributed per name, so `switch (domainEvent.name)` narrows `data`. +export type AnyDomainEvent = { + [Name in DomainEventName]: DomainEventEnvelope +}[DomainEventName] + +// What in-process delivery can offer beyond the envelope. Every field is +// optional and a listener must work without any of them. +export interface DomainEventDeliveryContext { + // Origin the triggering request arrived on. Carried for subscribers that + // build absolute links and fall back to the request origin when no canonical + // base URL is configured (see post-link-builder). + requestOrigin?: string + // The tenant slug the request arrived on — a readable handle for the org + // that orgId alone cannot give a subscriber without a lookup. Deliberately + // not in the envelope: a slug is a mutable label on the org, not part of + // the fact, and it comes from the request rather than the business flow. + orgSlug?: string + // The request that published the event, so a subscriber can read whatever + // else it needs — headers, cookies, locale — without this interface growing + // a field per use. Three constraints ride along: + // - Absent whenever nothing published this from a live request, so a + // subscriber that leans on it degrades silently rather than failing. + // - Read it, never write to it. Listeners run after the response may have + // been sent, so touching the response side throws or does nothing. + // - Read it synchronously at the top of the handler. Some runtimes tear + // the request down once the response completes, which is before a + // listener's later awaits resume. + request?: H3Event +} + +export type DomainEventHandler = ( + domainEvent: DomainEventEnvelope, + context?: DomainEventDeliveryContext, +) => void | Promise + +// One exact Nitro hook per fact: `feedback.created` → `feedlog:feedback:created`. +// Narrow subscribers register on exactly the fact they care about and the +// callback arrives already typed; there is no single shared hook and no +// wildcard. The event name (not the hook name) is the transport-independent +// contract other delivery paths reuse. +export type DomainEventHookName = + Name extends `${infer Domain}.${infer Fact}` ? `feedlog:${Domain}:${Fact}` : never + +type FeedLogDomainEventHooks = { + [Name in DomainEventName as DomainEventHookName]: DomainEventHandler +} + +declare module 'nitropack/types' { + // Declaration merging into Nitro's hook table — the "empty" interface is the merge. + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface NitroRuntimeHooks extends FeedLogDomainEventHooks {} +} + +function toHookName(name: DomainEventName): DomainEventHookName { + // Event names carry exactly one dot (domain.fact), so replacing the first + // occurrence is replacing the only one. + return `feedlog:${name.replace('.', ':')}` as DomainEventHookName +} + +export type DomainEventInput = Omit< + DomainEventEnvelope, + 'id' | 'occurredAt' +> + +// Creation is separate from publication so a caller can persist the event +// (keeping its id) before publishing, and so occurredAt names the moment the +// fact completed rather than the moment listeners run. Call it right after the +// transaction that established the fact commits — never inside it, or a +// rollback would leave listeners told of a fact that does not exist. +export function createDomainEvent( + input: DomainEventInput, +): DomainEventEnvelope { + return { + ...input, + id: uuidv7(), + occurredAt: new Date().toISOString(), + } +} + +// Schedules listeners via event.waitUntil so they run outside the response +// lifecycle: the response never waits for a subscriber, and the runtime still +// keeps the process alive until listeners finish where the platform supports it. +export function publishDomainEvent( + event: H3Event, + domainEvent: DomainEventEnvelope, +): void { + const context: DomainEventDeliveryContext = { + requestOrigin: getRequestURL(event).origin, + orgSlug: event.context.orgSlug, + request: event, + } + event.waitUntil(dispatchDomainEvent(useNitroApp(), domainEvent, context)) +} + +// Hookable's callHook runs listeners sequentially and rejects on the first +// error, which would let one subscriber starve or fail the others. Dispatch +// therefore goes through callHookWith with allSettled: every listener runs, +// every failure is contained. Failure logs carry only the event id, name and +// an error summary — never the payload. +async function dispatchDomainEvent( + nitroApp: NitroApp, + domainEvent: DomainEventEnvelope, + context: DomainEventDeliveryContext, +): Promise { + // The typed hook table pairs each hook with its exact envelope, which is + // right for subscribers but cannot express "this union-typed event goes to + // the hook derived from its own name". Erase the typing here; the pairing + // holds by construction. + const hooks = nitroApp.hooks as unknown as { + callHookWith: ( + caller: (handlers: DomainEventHandler[]) => Promise, + name: string, + ...args: unknown[] + ) => Promise + } + await hooks.callHookWith( + async (handlers) => { + // The async wrapper turns a listener's synchronous throw into a + // rejection; bare handler(...) would escape allSettled mid-map. + const outcomes = await Promise.allSettled(handlers.map(async handler => handler(domainEvent, context))) + for (const outcome of outcomes) { + if (outcome.status === 'rejected') { + console.error( + `[domain-events] listener failed event=${domainEvent.id} name=${domainEvent.name}: ${errorSummary(outcome.reason)}`, + ) + } + } + }, + toHookName(domainEvent.name), + domainEvent, + context, + ) +} + +function errorSummary(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason) +} + +// Both helpers register through nitroApp.hooks, so a listener added directly +// via nitroApp.hooks.hook('feedlog:...') and one added here share the same +// table and interoperate. +export function onDomainEvent( + nitroApp: NitroApp, + name: Name, + handler: DomainEventHandler, +): void { + // The generic name defeats Hookable's per-key callback inference; the + // handler's own signature already enforces the pairing. + nitroApp.hooks.hook(toHookName(name), handler as DomainEventHandler as never) +} + +// Cross-cutting subscription: registers the handler on every catalog entry, +// because Hookable has no wildcard and faking one would bypass typed hooks. +export function onAnyDomainEvent( + nitroApp: NitroApp, + handler: (domainEvent: AnyDomainEvent, context?: DomainEventDeliveryContext) => void | Promise, +): void { + for (const name of DOMAIN_EVENT_NAMES) { + nitroApp.hooks.hook(toHookName(name), handler as never) + } +} From 389f7e2088a138fb71f1399bff75a0b4d189692a Mon Sep 17 00:00:00 2001 From: canghai118 <3394863+canghai118@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:28:52 +0000 Subject: [PATCH 2/2] feat: mark stable UI actions with data-fdl-action attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The attribute value is the action's own name, so anything reading it — end-to-end tests, a deployment's own instrumentation — needs no lookup table. data-fdl-source tells apart entry points that trigger the same action. --- app/components/WidgetEmbed/WidgetEmbedConversationList.vue | 1 + app/components/WidgetEmbed/WidgetEmbedFeedbackCard.vue | 2 ++ app/components/WidgetEmbed/WidgetEmbedFeedbackList.vue | 2 ++ app/components/board/BoardSearchToolbar.vue | 2 ++ app/components/changelog/ChangelogReactions.vue | 4 ++++ app/components/comment/CommentEditor.vue | 4 ++++ app/components/post/PostDetail.vue | 2 ++ app/components/post/SimilarPostsHint.vue | 3 +++ app/components/post/SubmitModal.vue | 1 + app/pages/index.vue | 6 ++++++ app/pages/widget/embed.vue | 1 + 11 files changed, 28 insertions(+) diff --git a/app/components/WidgetEmbed/WidgetEmbedConversationList.vue b/app/components/WidgetEmbed/WidgetEmbedConversationList.vue index 6d4a5f1..ff9f293 100644 --- a/app/components/WidgetEmbed/WidgetEmbedConversationList.vue +++ b/app/components/WidgetEmbed/WidgetEmbedConversationList.vue @@ -42,6 +42,7 @@ function rowTitle(c: WidgetConversationItem): string {