Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/components/WidgetEmbed/WidgetEmbedConversationList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function rowTitle(c: WidgetConversationItem): string {
<div class="flex-1 overflow-y-auto">
<button
class="w-full flex items-center gap-3.5 px-5 py-3.5 text-left border-b border-border hover:bg-secondary/55 transition-colors"
data-fdl-action="widget_my_feedback_open"
@click="emit('openFeedback')"
>
<span class="w-8.5 h-8.5 shrink-0 inline-flex items-center justify-center text-primary">
Expand Down
2 changes: 2 additions & 0 deletions app/components/WidgetEmbed/WidgetEmbedFeedbackCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const { t } = useI18n()
<template>
<button
class="w-full text-left mt-2 min-w-[230px] px-3 py-2.5 rounded-md border border-border bg-background hover:border-primary/50 transition-colors group"
data-fdl-action="widget_feedback_open"
data-fdl-source="ai-card"
@click="$emit('open')"
>
<p class="text-[13px] font-semibold leading-snug">{{ title }}</p>
Expand Down
2 changes: 2 additions & 0 deletions app/components/WidgetEmbed/WidgetEmbedFeedbackList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ onUnmounted(() => listObserver?.disconnect())
<li v-for="item in items" :key="item.id">
<button
class="w-full text-left px-3 py-2.5 rounded-md border border-border bg-card hover:border-primary/40 transition-colors group"
data-fdl-action="widget_feedback_open"
data-fdl-source="list"
@click="emit('open', item)"
>
<div class="flex items-start gap-2">
Expand Down
2 changes: 2 additions & 0 deletions app/components/board/BoardSearchToolbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,8 @@ defineExpose({ reset })
<Button
v-show="!searchOpenMobile"
class="h-10 px-4 rounded-lg text-[15px] font-heading font-semibold"
data-fdl-action="feedback_composer_open"
data-fdl-source="toolbar"
@click="emit('new-request')"
>
<Icon name="lucide:plus" size="18" />
Expand Down
4 changes: 4 additions & 0 deletions app/components/changelog/ChangelogReactions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,8 @@ function handleDocumentClick(event: MouseEvent) {
:class="picked[emoji]
? 'border-primary bg-primary/12 text-foreground ring-1 ring-primary/30'
: 'border-border bg-background text-muted-foreground hover:border-primary/50 hover:text-foreground active:bg-secondary/40'"
data-fdl-action="changelog_react"
data-fdl-source="chip"
@click="toggle(emoji)"
>
<span class="leading-none select-none">{{ emoji }}</span>
Expand All @@ -143,6 +145,8 @@ function handleDocumentClick(event: MouseEvent) {
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-lg hover:bg-secondary/70 active:bg-secondary touch-manipulation transition-colors"
:class="picked[emoji] ? 'bg-primary/12 text-foreground ring-1 ring-primary/35' : ''"
:aria-label="$t('changelog.reactWith', { emoji })"
data-fdl-action="changelog_react"
data-fdl-source="picker"
@click="toggleFromPicker(emoji)"
>
{{ emoji }}
Expand Down
4 changes: 4 additions & 0 deletions app/components/comment/CommentEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,15 @@ defineExpose({ clear })
{{ $t('common.cancel') }}
</Button>
<div class="flex items-center">
<!-- Absent while editing: saving an edit is a different action, and
this one button serves both. -->
<Button
variant="default"
size="sm"
:class="showNotify ? 'rounded-r-none' : ''"
:disabled="submitDisabled"
:data-fdl-action="isEditing ? undefined : 'comment_submit'"
:data-fdl-source="isEditing ? undefined : (isReply ? 'reply' : 'post')"
@click="handleSubmit"
>
{{ isEditing ? $t('common.save') : (isReply ? $t('post.comment.reply') : $t('post.comment.comment')) }}
Expand Down
2 changes: 2 additions & 0 deletions app/components/post/PostDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,8 @@ async function handleShare() {
:class="post.hasVoted
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background text-foreground border-border hover:border-primary hover:text-primary'"
data-fdl-action="feedback_vote"
data-fdl-source="detail"
@click="handleVote"
>
<Icon name="lucide:chevron-up" size="28" />
Expand Down
3 changes: 3 additions & 0 deletions app/components/post/SimilarPostsHint.vue
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function statusConfig(status: string) {
<!-- Header (toggle) -->
<button
class="w-full flex items-center justify-between cursor-pointer select-none px-3.5 py-2.5"
data-fdl-action="similar_feedback_open"
@click="expanded = !expanded"
>
<div class="flex items-center gap-2 text-primary">
Expand Down Expand Up @@ -73,6 +74,8 @@ function statusConfig(status: string) {
v-for="sp in similarPosts"
:key="sp.id"
class="group cursor-pointer flex flex-col justify-center py-2 px-3.5 hover:bg-secondary/40 transition-colors"
data-fdl-action="feedback_open"
data-fdl-source="similar-list"
@click="emit('select', sp)"
>
<div class="flex items-center justify-between gap-3">
Expand Down
1 change: 1 addition & 0 deletions app/components/post/SubmitModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ watch(open, (v) => {
<button
class="w-full h-11 bg-primary hover:bg-primary/90 text-primary-foreground text-[15px] font-heading font-bold rounded-[10px] transition-all transform active:scale-[0.99] shadow-lg shadow-primary/20 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
:disabled="submitting"
data-fdl-action="feedback_submit"
@click="handleSubmit"
>
<template v-if="submitting">{{ $t('post.submit.submitting') }}</template>
Expand Down
6 changes: 6 additions & 0 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@ async function handleVote(post: PostListItem) {
<p class="fl-empty__hint">{{ $t('board.noMatchesHint') }}</p>
<Button
class="h-10 px-4 rounded-lg text-[15px] font-heading font-semibold"
data-fdl-action="feedback_composer_open"
data-fdl-source="empty-state"
@click="openSubmit()"
>
<Icon name="lucide:plus" size="18" />
Expand All @@ -397,6 +399,8 @@ async function handleVote(post: PostListItem) {
v-for="p in posts"
:key="p.id"
class="feedback-card flex items-stretch gap-4 bg-card border border-border rounded-lg p-4 cursor-pointer"
data-fdl-action="feedback_open"
data-fdl-source="board-list"
@click="openPostDetail(p)"
>
<!-- Upvote button -->
Expand All @@ -405,6 +409,8 @@ async function handleVote(post: PostListItem) {
:class="p.hasVoted
? 'bg-primary text-primary-foreground border-primary shadow-sm'
: 'bg-background text-foreground border-border hover:border-primary hover:text-primary transition-colors'"
data-fdl-action="feedback_vote"
data-fdl-source="board-list"
@click.stop="handleVote(p)"
>
<Icon name="lucide:chevron-up" size="24" />
Expand Down
1 change: 1 addition & 0 deletions app/pages/widget/embed.vue
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ onUnmounted(() => {
<button
class="w-6.5 h-6.5 rounded-full bg-secondary hover:opacity-80 transition-opacity flex items-center justify-center text-primary shrink-0"
:aria-label="t('widget.close')"
data-fdl-action="widget_panel_close"
@click="protocol.requestClose()"
>
<Icon name="lucide:x" size="13" />
Expand Down
25 changes: 6 additions & 19 deletions server/api/posts/index.post.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { getRequestURL } from 'h3'
import { createPostSchema } from '#layers/feedlog/shared/schemas/post'
import { isActorAdmin } from '#layers/feedlog/shared/utils/notifications'

Expand All @@ -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)

Expand Down
69 changes: 48 additions & 21 deletions server/api/widget/messages.post.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -136,7 +135,7 @@ export default defineEventHandler(async (event): Promise<WidgetMessageResponse>
// 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)
Expand All @@ -149,13 +148,31 @@ export default defineEventHandler(async (event): Promise<WidgetMessageResponse>
.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({
Expand All @@ -180,15 +197,28 @@ export default defineEventHandler(async (event): Promise<WidgetMessageResponse>
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}` })
}
}

// 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
Expand Down Expand Up @@ -253,24 +283,21 @@ export default defineEventHandler(async (event): Promise<WidgetMessageResponse>
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 {
Expand Down
64 changes: 64 additions & 0 deletions server/plugins/feedback-listeners.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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,
})
})
})
Loading
Loading