Enhance chat functionality by adding user last used shortcuts tracking and updating request handling to include metadata. Refactor shortcut listing to prioritize last used shortcuts in the response.

This commit is contained in:
dal 2025-09-12 11:09:15 -06:00
parent cddd790761
commit e865dc79c4
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
16 changed files with 180 additions and 28 deletions

View File

@ -1,4 +1,4 @@
import { getUserOrganizationId } from '@buster/database';
import { getUserOrganizationId, updateUserLastUsedShortcuts } from '@buster/database';
import type { User } from '@buster/database';
import {
type ChatCreateHandlerRequest,
@ -9,7 +9,6 @@ import {
import { tasks } from '@trigger.dev/sdk';
import { handleAssetChat, handleAssetChatWithPrompt } from './services/chat-helpers';
import { initializeChat } from './services/chat-service';
import { enhanceMessageWithShortcut } from './services/shortcut-service';
/**
* Handler function for creating a new chat.
@ -57,20 +56,13 @@ export async function createChatHandler(
throw new ChatError(ChatErrorCode.INVALID_REQUEST, 'prompt or asset_id is required', 400);
}
// Process shortcuts if present
let enhancedPrompt = request.prompt;
if (request.prompt) {
// Check for shortcut pattern in message (e.g., /weekly-report)
enhancedPrompt = await enhanceMessageWithShortcut(request.prompt, user.id, organizationId);
}
// Set message_analysis_mode if not provided (from staging)
if (request.prompt && !request.message_analysis_mode) {
request.message_analysis_mode = 'auto';
}
// Update request with enhanced prompt and message_analysis_mode
const processedRequest = { ...request, prompt: enhancedPrompt };
// Update request with message_analysis_mode
const processedRequest = { ...request };
// Initialize chat (new or existing)
// When we have both asset and prompt, we'll skip creating the initial message
// since handleAssetChatWithPrompt will create both the import and prompt messages
@ -85,6 +77,17 @@ export async function createChatHandler(
const { chatId, messageId, chat } = await initializeChat(modifiedRequest, user, organizationId);
// Update user's last used shortcuts if any were provided in metadata
if (
processedRequest.metadata?.shortcutIds &&
processedRequest.metadata.shortcutIds.length > 0
) {
await updateUserLastUsedShortcuts({
userId: user.id,
shortcutIds: processedRequest.metadata.shortcutIds,
});
}
// Handle asset-based chat if needed
let finalChat: ChatWithMessages = chat;
let actualMessageId = messageId; // Track the actual message ID to use for triggering

View File

@ -18,6 +18,7 @@ import type {
ChatMessageResponseMessage,
ChatWithMessages,
MessageAnalysisMode,
MessageMetadata,
} from '@buster/server-shared/chats';
import { ChatError, ChatErrorCode } from '@buster/server-shared/chats';
import { PostProcessingMessageSchema } from '@buster/server-shared/message';
@ -204,7 +205,8 @@ export async function handleExistingChat(
prompt: string | undefined,
messageAnalysisMode: MessageAnalysisMode | undefined,
user: User,
redoFromMessageId?: string
redoFromMessageId?: string,
metadata?: MessageMetadata
): Promise<{
chatId: string;
messageId: string;
@ -262,6 +264,7 @@ export async function handleExistingChat(
messageAnalysisMode: messageAnalysisMode,
userId: user.id,
messageId,
metadata,
})
: Promise.resolve(null),
getMessagesForChat(chatId),
@ -295,6 +298,7 @@ export async function handleNewChat({
messageAnalysisMode,
user,
organizationId,
metadata,
}: {
title: string;
messageId: string;
@ -302,6 +306,7 @@ export async function handleNewChat({
messageAnalysisMode: MessageAnalysisMode | undefined;
user: User;
organizationId: string;
metadata?: MessageMetadata;
}): Promise<{
chatId: string;
messageId: string;
@ -346,6 +351,7 @@ export async function handleNewChat({
content: prompt,
} as ModelMessage,
],
metadata: metadata || {},
})
.returning();

View File

@ -47,7 +47,8 @@ export async function initializeChat(
request.prompt,
request.message_analysis_mode,
user,
redoFromMessageId
redoFromMessageId,
request.metadata
);
}
@ -59,6 +60,7 @@ export async function initializeChat(
messageAnalysisMode: request.message_analysis_mode,
user,
organizationId,
metadata: request.metadata,
});
} catch (error) {
// Log detailed error context

View File

@ -1,5 +1,6 @@
import type { User } from '@buster/database';
import { getUserOrganizationId, getUserShortcuts } from '@buster/database';
import { db, getUserOrganizationId, getUserShortcuts, users } from '@buster/database';
import { eq } from '@buster/database';
import type { ListShortcutsResponse } from '@buster/server-shared/shortcuts';
import { HTTPException } from 'hono/http-exception';
@ -16,14 +17,47 @@ export async function listShortcutsHandler(user: User): Promise<ListShortcutsRes
const { organizationId } = userOrg;
// Get all accessible shortcuts (personal + workspace) sorted alphabetically
// Get user's lastUsedShortcuts array
const [userRecord] = await db
.select({ lastUsedShortcuts: users.lastUsedShortcuts })
.from(users)
.where(eq(users.id, user.id))
.limit(1);
const lastUsedShortcutIds = userRecord?.lastUsedShortcuts || [];
// Get all accessible shortcuts (personal + workspace)
const shortcuts = await getUserShortcuts({
userId: user.id,
organizationId,
});
// Sort shortcuts by last used order
const sortedShortcuts = shortcuts.sort((a, b) => {
const aIndex = lastUsedShortcutIds.indexOf(a.id);
const bIndex = lastUsedShortcutIds.indexOf(b.id);
// If both are in lastUsed, sort by their position
if (aIndex !== -1 && bIndex !== -1) {
return aIndex - bIndex;
}
// If only a is in lastUsed, it comes first
if (aIndex !== -1) {
return -1;
}
// If only b is in lastUsed, it comes first
if (bIndex !== -1) {
return 1;
}
// Neither is in lastUsed, sort alphabetically
return a.name.localeCompare(b.name);
});
return {
shortcuts,
shortcuts: sortedShortcuts,
};
} catch (error) {
console.error('Error in listShortcutsHandler:', {

View File

@ -53,10 +53,11 @@ export async function updateShortcutHandler(
// Only workspace_admin, data_admin, or the creator can update workspace shortcuts
const isAdmin = userOrg.role === 'workspace_admin' || userOrg.role === 'data_admin';
const isCreator = existingShortcut.createdBy === user.id;
if (!isAdmin && !isCreator) {
throw new HTTPException(403, {
message: 'Only workspace admins, data admins, or the shortcut creator can update workspace shortcuts',
message:
'Only workspace admins, data admins, or the shortcut creator can update workspace shortcuts',
});
}
}

View File

@ -8,11 +8,11 @@ import { z } from 'zod';
import { requireAuth } from '../../../middleware/auth';
import '../../../types/hono.types';
import { HTTPException } from 'hono/http-exception';
import { createShortcutHandler } from './create-shortcut';
import { deleteShortcutHandler } from './delete-shortcut';
import { getShortcutHandler } from './get-shortcut';
import { listShortcutsHandler } from './list-shortcuts';
import { updateShortcutHandler } from './update-shortcut';
import { listShortcutsHandler } from './GET';
import { createShortcutHandler } from './POST';
import { deleteShortcutHandler } from './[id]/DELETE';
import { getShortcutHandler } from './[id]/GET';
import { updateShortcutHandler } from './[id]/PUT';
// Schema for path params
const shortcutIdParamSchema = z.object({

View File

@ -12,7 +12,9 @@ CREATE TABLE "shortcuts" (
CONSTRAINT "shortcuts_personal_unique" UNIQUE("name","organization_id","created_by")
);
--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "suggested_prompts" SET DEFAULT '{"suggestedPrompts":{"report":["provide a trend analysis of quarterly profits","evaluate product performance across regions"],"dashboard":["create a sales performance dashboard","design a revenue forecast dashboard"],"visualization":["create a metric for monthly sales","show top vendors by purchase volume"],"help":["what types of analyses can you perform?","what questions can I as buster?","what data models are available for queries?","can you explain your forecasting capabilities?"]},"updatedAt":"2025-09-12T06:59:42.400Z"}'::jsonb;--> statement-breakpoint
ALTER TABLE "users" ALTER COLUMN "suggested_prompts" SET DEFAULT '{"suggestedPrompts":{"report":["provide a trend analysis of quarterly profits","evaluate product performance across regions"],"dashboard":["create a sales performance dashboard","design a revenue forecast dashboard"],"visualization":["create a metric for monthly sales","show top vendors by purchase volume"],"help":["what types of analyses can you perform?","what questions can I as buster?","what data models are available for queries?","can you explain your forecasting capabilities?"]},"updatedAt":"2025-09-12T16:57:43.191Z"}'::jsonb;--> statement-breakpoint
ALTER TABLE "messages" ADD COLUMN "metadata" jsonb DEFAULT '{}'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "last_used_shortcuts" jsonb DEFAULT '[]'::jsonb NOT NULL;--> statement-breakpoint
ALTER TABLE "shortcuts" ADD CONSTRAINT "shortcuts_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "shortcuts" ADD CONSTRAINT "shortcuts_updated_by_fkey" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE cascade;--> statement-breakpoint
ALTER TABLE "shortcuts" ADD CONSTRAINT "shortcuts_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint

View File

@ -1,5 +1,5 @@
{
"id": "a2ebcfaa-f335-4dc4-8821-9d0bf865d27d",
"id": "0613296e-fdd2-4a35-9d79-2275d0c7a78e",
"prevId": "f71f7e1c-314d-413d-8da9-a525bd4a3519",
"version": "7",
"dialect": "postgresql",
@ -3020,6 +3020,13 @@
"type": "text",
"primaryKey": false,
"notNull": false
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
}
},
"indexes": {
@ -6907,7 +6914,7 @@
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{\"suggestedPrompts\":{\"report\":[\"provide a trend analysis of quarterly profits\",\"evaluate product performance across regions\"],\"dashboard\":[\"create a sales performance dashboard\",\"design a revenue forecast dashboard\"],\"visualization\":[\"create a metric for monthly sales\",\"show top vendors by purchase volume\"],\"help\":[\"what types of analyses can you perform?\",\"what questions can I as buster?\",\"what data models are available for queries?\",\"can you explain your forecasting capabilities?\"]},\"updatedAt\":\"2025-09-12T06:59:42.400Z\"}'::jsonb"
"default": "'{\"suggestedPrompts\":{\"report\":[\"provide a trend analysis of quarterly profits\",\"evaluate product performance across regions\"],\"dashboard\":[\"create a sales performance dashboard\",\"design a revenue forecast dashboard\"],\"visualization\":[\"create a metric for monthly sales\",\"show top vendors by purchase volume\"],\"help\":[\"what types of analyses can you perform?\",\"what questions can I as buster?\",\"what data models are available for queries?\",\"can you explain your forecasting capabilities?\"]},\"updatedAt\":\"2025-09-12T16:57:43.191Z\"}'::jsonb"
},
"personalization_enabled": {
"name": "personalization_enabled",
@ -6922,6 +6929,13 @@
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"last_used_shortcuts": {
"name": "last_used_shortcuts",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'[]'::jsonb"
}
},
"indexes": {},

View File

@ -677,8 +677,8 @@
{
"idx": 96,
"version": "7",
"when": 1757660382434,
"tag": "0096_secret_next_avengers",
"when": 1757696263222,
"tag": "0096_outgoing_shotgun",
"breakpoints": true
}
]

View File

@ -35,6 +35,7 @@ export const CreateMessageInputSchema = z.object({
userId: z.string().uuid(),
messageId: z.string().uuid().optional(),
messageAnalysisMode: z.enum(messageAnalysisModeEnum.enumValues).optional(),
metadata: z.record(z.any()).optional(),
});
export type CreateChatInput = z.infer<typeof CreateChatInputSchema>;
@ -146,6 +147,7 @@ export async function createMessage(input: CreateMessageInput): Promise<Message>
content: validated.content,
},
],
metadata: validated.metadata || {},
})
.returning();

View File

@ -5,3 +5,4 @@ export * from './get-user-organizations';
export * from './user-queries';
export * from './user-suggested-prompts';
export * from './get-user-personalization';
export * from './update-user-shortcuts';

View File

@ -0,0 +1,62 @@
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { users } from '../../schema';
export const UpdateUserLastUsedShortcutsInputSchema = z.object({
userId: z.string().uuid(),
shortcutIds: z.array(z.string().uuid()),
});
export type UpdateUserLastUsedShortcutsInput = z.infer<
typeof UpdateUserLastUsedShortcutsInputSchema
>;
/**
* Updates the user's lastUsedShortcuts array with new shortcut IDs.
* New shortcuts are prepended to the front of the array.
* Duplicates are removed while maintaining the most recent usage order.
*
* @param input - Object containing userId and shortcutIds to add
* @returns Success status
*/
export async function updateUserLastUsedShortcuts(
input: UpdateUserLastUsedShortcutsInput
): Promise<{ success: boolean }> {
const validated = UpdateUserLastUsedShortcutsInputSchema.parse(input);
try {
// First, get the current lastUsedShortcuts array
const [currentUser] = await db
.select({ lastUsedShortcuts: users.lastUsedShortcuts })
.from(users)
.where(eq(users.id, validated.userId))
.limit(1);
if (!currentUser) {
throw new Error(`User not found: ${validated.userId}`);
}
const currentShortcuts = currentUser.lastUsedShortcuts || [];
// Remove any of the new shortcuts from their current positions
const filteredShortcuts = currentShortcuts.filter((id) => !validated.shortcutIds.includes(id));
// Prepend the new shortcuts to the front
const updatedShortcuts = [...validated.shortcutIds, ...filteredShortcuts];
// Update the user record
await db
.update(users)
.set({
lastUsedShortcuts: updatedShortcuts,
updatedAt: new Date().toISOString(),
})
.where(eq(users.id, validated.userId));
return { success: true };
} catch (error) {
console.error('Failed to update user last used shortcuts:', error);
return { success: false };
}
}

View File

@ -16,8 +16,13 @@ export const UserPersonalizationConfigSchema = z.object({
additionalInformation: z.string().optional(),
});
export const UserShortcutTrackingSchema = z.object({
lastUsedShortcuts: z.array(z.string().uuid()).default([]),
});
export type UserSuggestedPromptsType = z.infer<typeof UserSuggestedPromptsSchema>;
export type UserPersonalizationConfigType = z.infer<typeof UserPersonalizationConfigSchema>;
export type UserShortcutTrackingType = z.infer<typeof UserShortcutTrackingSchema>;
export const DEFAULT_USER_SUGGESTED_PROMPTS: UserSuggestedPromptsType = {
suggestedPrompts: {

View File

@ -22,9 +22,11 @@ import {
import type {
OrganizationColorPalettes,
UserPersonalizationConfigType,
UserShortcutTrackingType,
UserSuggestedPromptsType,
} from './schema-types';
import { DEFAULT_USER_SUGGESTED_PROMPTS } from './schema-types/user';
import type { MessageMetadata } from './schemas/message-schemas';
export const assetPermissionRoleEnum = pgEnum('asset_permission_role_enum', [
'owner',
@ -879,6 +881,7 @@ export const users = pgTable(
.$type<UserPersonalizationConfigType>()
.default({})
.notNull(),
lastUsedShortcuts: jsonb('last_used_shortcuts').$type<string[]>().default([]).notNull(),
},
(table) => [unique('users_email_key').on(table.email)]
);
@ -907,6 +910,7 @@ export const messages = pgTable(
isCompleted: boolean('is_completed').default(false).notNull(),
postProcessingMessage: jsonb('post_processing_message'),
triggerRunId: text('trigger_run_id'),
metadata: jsonb().$type<MessageMetadata>().default({}).notNull(),
},
(table) => [
index('messages_chat_id_idx').using('btree', table.chatId.asc().nullsLast().op('uuid_ops')),

View File

@ -139,3 +139,10 @@ export type ChatMessageResponseMessage_File = z.infer<typeof ResponseMessage_Fil
export type ReasoningFileType = z.infer<typeof ReasoningFileTypeSchema>;
export type ResponseMessageFileType = z.infer<typeof ResponseMessageFileTypeSchema>;
export type ReasoingMessage_ThoughtFileType = z.infer<typeof ReasoingMessage_ThoughtFileTypeSchema>;
// Message metadata schema
export const MessageMetadataSchema = z.object({
shortcutIds: z.array(z.string().uuid()).nullable().optional(),
});
export type MessageMetadata = z.infer<typeof MessageMetadataSchema>;

View File

@ -4,6 +4,13 @@ import type { AssetPermissionRoleSchema } from '../share';
import { ShareConfigSchema } from '../share';
import { ChatMessageSchema } from './chat-message.types';
// Message metadata schema (mirrored from database package)
export const MessageMetadataSchema = z.object({
shortcutIds: z.array(z.string().uuid()).nullable().optional(),
});
export type MessageMetadata = z.infer<typeof MessageMetadataSchema>;
// Asset Permission Role enum (matching database enum)
export const ChatAssetTypeSchema = AssetTypeSchema.exclude(['chat', 'collection']);
@ -34,6 +41,7 @@ export const ChatCreateRequestSchema = z
message_analysis_mode: MessageAnalysisModeSchema.optional(),
asset_id: z.string().optional(),
asset_type: ChatAssetTypeSchema.optional(),
metadata: MessageMetadataSchema.optional(),
// Legacy fields for backward compatibility
metric_id: z.string().optional(),
dashboard_id: z.string().optional(),
@ -51,6 +59,7 @@ export const ChatCreateHandlerRequestSchema = z.object({
message_id: z.string().optional(),
asset_id: z.string().optional(),
asset_type: ChatAssetTypeSchema.optional(),
metadata: MessageMetadataSchema.optional(),
});
// Cancel chat params schema