Skip to content

Commit 917dff7

Browse files
committed
feat: assistant feedback
1 parent 91f114b commit 917dff7

File tree

3 files changed

+364
-0
lines changed

3 files changed

+364
-0
lines changed

packages/db/src/drizzle/schema.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,3 +798,68 @@ export const abGoals = pgTable(
798798
.onDelete('cascade'),
799799
]
800800
);
801+
802+
export const assistantConversations = pgTable(
803+
'assistant_conversations',
804+
{
805+
id: text().primaryKey().notNull(),
806+
userId: text('user_id'),
807+
websiteId: text('website_id').notNull(),
808+
title: text(),
809+
createdAt: timestamp('created_at', { mode: 'string' })
810+
.default(sql`CURRENT_TIMESTAMP`)
811+
.notNull(),
812+
updatedAt: timestamp('updated_at', { mode: 'string' })
813+
.default(sql`CURRENT_TIMESTAMP`)
814+
.notNull(),
815+
},
816+
(table) => [
817+
index('assistant_conversations_website_id_idx').using(
818+
'btree',
819+
table.websiteId.asc().nullsLast().op('text_ops')
820+
),
821+
foreignKey({
822+
columns: [table.userId],
823+
foreignColumns: [user.id],
824+
name: 'assistant_conversations_user_id_fkey',
825+
}).onDelete('set null'),
826+
foreignKey({
827+
columns: [table.websiteId],
828+
foreignColumns: [websites.id],
829+
name: 'assistant_conversations_website_id_fkey',
830+
}).onDelete('cascade'),
831+
]
832+
);
833+
834+
export const assistantMessages = pgTable(
835+
'assistant_messages',
836+
{
837+
id: text().primaryKey().notNull(),
838+
conversationId: text('conversation_id').notNull(),
839+
userMessage: text('user_message').notNull(),
840+
modelType: text('model_type').notNull(),
841+
responseType: text('response_type').notNull(),
842+
finalResponse: text('final_response'),
843+
sqlQuery: text('sql_query'),
844+
chartData: jsonb('chart_data'),
845+
hasError: boolean('has_error').default(false).notNull(),
846+
errorMessage: text('error_message'),
847+
upvotes: integer('upvotes').default(0).notNull(),
848+
downvotes: integer('downvotes').default(0).notNull(),
849+
feedbackComments: jsonb('feedback_comments'),
850+
createdAt: timestamp('created_at', { mode: 'string' })
851+
.default(sql`CURRENT_TIMESTAMP`)
852+
.notNull(),
853+
},
854+
(table) => [
855+
index('assistant_messages_conversation_id_idx').using(
856+
'btree',
857+
table.conversationId.asc().nullsLast().op('text_ops')
858+
),
859+
foreignKey({
860+
columns: [table.conversationId],
861+
foreignColumns: [assistantConversations.id],
862+
name: 'assistant_messages_conversation_id_fkey',
863+
}).onDelete('cascade'),
864+
]
865+
);

packages/rpc/src/root.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { apikeysRouter } from './routers/apikeys';
2+
import { assistantRouter } from './routers/assistant';
23
import { autocompleteRouter } from './routers/autocomplete';
34
import { experimentsRouter } from './routers/experiments';
45
import { funnelsRouter } from './routers/funnels';
@@ -17,6 +18,7 @@ export const appRouter = createTRPCRouter({
1718
autocomplete: autocompleteRouter,
1819
apikeys: apikeysRouter,
1920
experiments: experimentsRouter,
21+
assistant: assistantRouter,
2022
});
2123

2224
export type AppRouter = typeof appRouter;
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { TRPCError } from '@trpc/server';
2+
import { z } from 'zod';
3+
import { and, asc, desc, eq } from 'drizzle-orm';
4+
import { db, assistantConversations, assistantMessages } from '@databuddy/db';
5+
import { createTRPCRouter, protectedProcedure } from '../trpc';
6+
import { createId } from '@databuddy/shared';
7+
8+
export const assistantRouter = createTRPCRouter({
9+
// Save a conversation (creates conversation + message)
10+
saveConversation: protectedProcedure
11+
.input(
12+
z.object({
13+
websiteId: z.string(),
14+
title: z.string().optional(),
15+
userMessage: z.string(),
16+
modelType: z.string(),
17+
responseType: z.string(),
18+
finalResponse: z.string().optional(),
19+
sqlQuery: z.string().optional(),
20+
chartData: z.record(z.string(), z.unknown()).optional(),
21+
hasError: z.boolean().default(false),
22+
errorMessage: z.string().optional(),
23+
})
24+
)
25+
.mutation(async ({ ctx, input }) => {
26+
const conversationId = createId();
27+
const messageId = createId();
28+
29+
await db.transaction(async (tx) => {
30+
// Insert conversation
31+
await tx.insert(assistantConversations).values({
32+
id: conversationId,
33+
userId: ctx.user.id,
34+
websiteId: input.websiteId,
35+
title: input.title || `${input.userMessage.slice(0, 50)}...`,
36+
});
37+
38+
// Insert message
39+
await tx.insert(assistantMessages).values({
40+
id: messageId,
41+
conversationId,
42+
userMessage: input.userMessage,
43+
modelType: input.modelType,
44+
responseType: input.responseType,
45+
finalResponse: input.finalResponse,
46+
sqlQuery: input.sqlQuery,
47+
chartData: input.chartData,
48+
hasError: input.hasError,
49+
errorMessage: input.errorMessage,
50+
upvotes: 0,
51+
downvotes: 0,
52+
feedbackComments: null,
53+
});
54+
});
55+
56+
return { conversationId, messageId };
57+
}),
58+
59+
// Add message to existing conversation
60+
addMessage: protectedProcedure
61+
.input(
62+
z.object({
63+
conversationId: z.string(),
64+
userMessage: z.string(),
65+
modelType: z.string(),
66+
responseType: z.string(),
67+
finalResponse: z.string().optional(),
68+
sqlQuery: z.string().optional(),
69+
chartData: z.record(z.string(), z.unknown()).optional(),
70+
hasError: z.boolean().default(false),
71+
errorMessage: z.string().optional(),
72+
})
73+
)
74+
.mutation(async ({ ctx, input }) => {
75+
// Verify conversation exists and user has access
76+
const conversation = await db
77+
.select()
78+
.from(assistantConversations)
79+
.where(
80+
and(
81+
eq(assistantConversations.id, input.conversationId),
82+
eq(assistantConversations.userId, ctx.user.id)
83+
)
84+
)
85+
.limit(1);
86+
87+
if (!conversation[0]) {
88+
throw new TRPCError({
89+
code: 'NOT_FOUND',
90+
message: 'Conversation not found or access denied',
91+
});
92+
}
93+
94+
const messageId = createId();
95+
96+
await db.transaction(async (tx) => {
97+
// Insert message
98+
await tx.insert(assistantMessages).values({
99+
id: messageId,
100+
conversationId: input.conversationId,
101+
userMessage: input.userMessage,
102+
modelType: input.modelType,
103+
responseType: input.responseType,
104+
finalResponse: input.finalResponse,
105+
sqlQuery: input.sqlQuery,
106+
chartData: input.chartData,
107+
hasError: input.hasError,
108+
errorMessage: input.errorMessage,
109+
upvotes: 0,
110+
downvotes: 0,
111+
feedbackComments: null,
112+
});
113+
114+
// Update conversation timestamp
115+
await tx
116+
.update(assistantConversations)
117+
.set({ updatedAt: new Date().toISOString() })
118+
.where(eq(assistantConversations.id, input.conversationId));
119+
});
120+
121+
return { messageId };
122+
}),
123+
124+
// Get user's conversations
125+
getConversations: protectedProcedure
126+
.input(
127+
z.object({
128+
websiteId: z.string().optional(),
129+
limit: z.number().default(20),
130+
offset: z.number().default(0),
131+
})
132+
)
133+
.query(async ({ ctx, input }) => {
134+
const conversations = await db
135+
.select()
136+
.from(assistantConversations)
137+
.where(
138+
input.websiteId
139+
? and(
140+
eq(assistantConversations.userId, ctx.user.id),
141+
eq(assistantConversations.websiteId, input.websiteId)
142+
)
143+
: eq(assistantConversations.userId, ctx.user.id)
144+
)
145+
.orderBy(desc(assistantConversations.updatedAt))
146+
.limit(input.limit)
147+
.offset(input.offset);
148+
149+
return conversations;
150+
}),
151+
152+
// Get conversation with messages
153+
getConversation: protectedProcedure
154+
.input(z.object({ conversationId: z.string() }))
155+
.query(async ({ ctx, input }) => {
156+
const conversation = await db
157+
.select()
158+
.from(assistantConversations)
159+
.where(
160+
and(
161+
eq(assistantConversations.id, input.conversationId),
162+
eq(assistantConversations.userId, ctx.user.id)
163+
)
164+
)
165+
.limit(1);
166+
167+
if (!conversation[0]) {
168+
throw new TRPCError({
169+
code: 'NOT_FOUND',
170+
message: 'Conversation not found',
171+
});
172+
}
173+
174+
const messages = await db
175+
.select()
176+
.from(assistantMessages)
177+
.where(eq(assistantMessages.conversationId, input.conversationId))
178+
.orderBy(asc(assistantMessages.createdAt));
179+
180+
return {
181+
conversation: conversation[0],
182+
messages,
183+
};
184+
}),
185+
186+
// Add feedback to message
187+
addFeedback: protectedProcedure
188+
.input(
189+
z.object({
190+
messageId: z.string(),
191+
type: z.enum(['upvote', 'downvote']),
192+
comment: z.string().optional(),
193+
})
194+
)
195+
.mutation(async ({ ctx, input }) => {
196+
// Get message with conversation to verify user access
197+
const result = await db
198+
.select({
199+
message: assistantMessages,
200+
conversation: assistantConversations,
201+
})
202+
.from(assistantMessages)
203+
.innerJoin(
204+
assistantConversations,
205+
eq(assistantMessages.conversationId, assistantConversations.id)
206+
)
207+
.where(
208+
and(
209+
eq(assistantMessages.id, input.messageId),
210+
eq(assistantConversations.userId, ctx.user.id)
211+
)
212+
)
213+
.limit(1);
214+
215+
if (!result[0]) {
216+
throw new TRPCError({
217+
code: 'NOT_FOUND',
218+
message: 'Message not found or access denied',
219+
});
220+
}
221+
222+
const { message } = result[0];
223+
224+
// Update vote counts
225+
const updates: Partial<typeof assistantMessages.$inferInsert> = {};
226+
if (input.type === 'upvote') {
227+
updates.upvotes = message.upvotes + 1;
228+
} else {
229+
updates.downvotes = message.downvotes + 1;
230+
}
231+
232+
// Add comment if provided
233+
if (input.comment) {
234+
const existingComments = (message.feedbackComments as Array<{
235+
userId: string;
236+
comment: string;
237+
timestamp: string;
238+
}>) || [];
239+
updates.feedbackComments = [
240+
...existingComments,
241+
{
242+
userId: ctx.user.id,
243+
comment: input.comment,
244+
timestamp: new Date().toISOString(),
245+
},
246+
];
247+
}
248+
249+
await db
250+
.update(assistantMessages)
251+
.set(updates)
252+
.where(eq(assistantMessages.id, input.messageId));
253+
254+
return { success: true };
255+
}),
256+
257+
// Delete conversation
258+
deleteConversation: protectedProcedure
259+
.input(z.object({ conversationId: z.string() }))
260+
.mutation(async ({ ctx, input }) => {
261+
const result = await db
262+
.delete(assistantConversations)
263+
.where(
264+
and(
265+
eq(assistantConversations.id, input.conversationId),
266+
eq(assistantConversations.userId, ctx.user.id)
267+
)
268+
);
269+
270+
return { success: true };
271+
}),
272+
273+
// Update conversation title
274+
updateConversationTitle: protectedProcedure
275+
.input(
276+
z.object({
277+
conversationId: z.string(),
278+
title: z.string().min(1).max(100),
279+
})
280+
)
281+
.mutation(async ({ ctx, input }) => {
282+
await db
283+
.update(assistantConversations)
284+
.set({
285+
title: input.title,
286+
updatedAt: new Date().toISOString(),
287+
})
288+
.where(
289+
and(
290+
eq(assistantConversations.id, input.conversationId),
291+
eq(assistantConversations.userId, ctx.user.id)
292+
)
293+
);
294+
295+
return { success: true };
296+
}),
297+
});

0 commit comments

Comments
 (0)