Merge branch 'staging' into big-nate-bus-1756-finalize-suggestion-dropdown

This commit is contained in:
Nate Kelley 2025-09-29 11:55:49 -06:00
commit 31bb2aa062
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
25 changed files with 526 additions and 292 deletions

View File

@ -0,0 +1,86 @@
import { checkPermission } from '@buster/access-controls';
import type { User } from '@buster/database/queries';
import {
getChatWithDetails,
getMessagesForChatWithUserDetails,
} from '@buster/database/queries';
import {
type GetChatResponse,
GetChatRequestSchema,
} from '@buster/server-shared/chats';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { throwUnauthorizedError } from '../../../../shared-helpers/asset-public-access';
import { buildChatWithMessages } from '../services/chat-helpers';
interface GetChatHandlerParams {
chatId: string;
user: User;
}
const app = new Hono().get('/', zValidator('param', GetChatRequestSchema), async (c) => {
const { id } = c.req.valid('param');
const user = c.get('busterUser');
console.info(`Processing GET request for chat with ID: ${id}, user_id: ${user.id}`);
const response: GetChatResponse = await getChatHandler({ chatId: id, user });
return c.json(response);
});
export default app;
/**
* Handler to retrieve a chat by ID with messages and permissions
* This is the TypeScript equivalent of the Rust get_chat_handler
*/
export async function getChatHandler(params: GetChatHandlerParams): Promise<GetChatResponse> {
const { chatId, user } = params;
// Fetch chat with messages and related data
const chatData = await getChatWithDetails({
chatId,
userId: user.id,
});
if (!chatData) {
console.warn(`Chat not found: ${chatId}`);
throw new HTTPException(404, {
message: 'Chat not found',
});
}
const { chat, user: creator } = chatData;
// Check permissions using the access control system
const { hasAccess, effectiveRole } = await checkPermission({
userId: user.id,
assetId: chatId,
assetType: 'chat',
requiredRole: 'can_view',
organizationId: chat.organizationId,
workspaceSharing: chat.workspaceSharing || 'none',
publiclyAccessible: chat.publiclyAccessible || false,
publicExpiryDate: chat.publicExpiryDate ?? undefined,
});
if (!hasAccess || !effectiveRole) {
throwUnauthorizedError({
publiclyAccessible: chat.publiclyAccessible || false,
publicExpiryDate: chat.publicExpiryDate ?? undefined,
});
}
const messages = await getMessagesForChatWithUserDetails(chatId);
const response: GetChatResponse = await buildChatWithMessages(
chat,
messages,
creator,
effectiveRole
);
return response;
}

View File

@ -0,0 +1,8 @@
import { Hono } from 'hono';
import GET from './GET';
const app = new Hono();
app.route('/', GET);
export default app;

View File

@ -12,6 +12,7 @@ import '../../../types/hono.types'; //I added this to fix intermitent type error
import { HTTPException } from 'hono/http-exception';
import { z } from 'zod';
import GET from './GET';
import chatById from './[id]';
import { cancelChatHandler } from './cancel-chat';
import { createChatHandler } from './handler';
@ -19,6 +20,7 @@ const app = new Hono()
// Apply authentication middleware
.use('*', requireAuth)
.route('/', GET)
.route('/:id', chatById)
// POST /chats - Create a new chat
.post('/', zValidator('json', ChatCreateRequestSchema), async (c) => {
const request = c.req.valid('json');

View File

@ -1,4 +1,8 @@
import { canUserAccessChatCached } from '@buster/access-controls';
import {
type AssetPermissionRole,
canUserAccessChatCached,
checkPermission,
} from '@buster/access-controls';
import type { ModelMessage } from '@buster/ai';
import { db } from '@buster/database/connection';
import {
@ -7,7 +11,9 @@ import {
createMessage,
generateAssetMessages,
getChatWithDetails,
getMessagesForChat,
getMessagesForChatWithUserDetails,
getOrganizationMemberCount,
getUsersWithAssetPermissions,
} from '@buster/database/queries';
import type { Chat, Message } from '@buster/database/queries';
import { chats, messages } from '@buster/database/schema';
@ -24,6 +30,8 @@ import { ChatError, ChatErrorCode } from '@buster/server-shared/chats';
import { PostProcessingMessageSchema } from '@buster/server-shared/message';
import { and, eq, gte, isNull } from 'drizzle-orm';
import type { z } from 'zod';
import { throwUnauthorizedError } from '../../../../shared-helpers/asset-public-access';
import { getPubliclyEnabledByUser } from '../../../../shared-helpers/get-publicly-enabled-by-user';
/**
* Validates a nullable JSONB field against a Zod schema
@ -106,64 +114,72 @@ const buildReasoningMessages = (reasoningMessages: unknown): ChatMessage['reason
* Build a ChatWithMessages object from database entities
* Optimized for performance with pre-allocated objects and minimal iterations
*/
export function buildChatWithMessages(
export async function buildChatWithMessages(
chat: Chat,
messages: Message[],
messages: { message: Message; user: User }[],
user: User | null,
permission: AssetPermissionRole,
isFavorited = false
): ChatWithMessages {
): Promise<ChatWithMessages> {
const createdByName = user?.name || user?.email || 'Unknown User';
// Pre-allocate collections with known size
const messageCount = messages.length;
const messageMap: Record<string, ChatMessage> = {};
const messageIds: string[] = new Array(messageCount);
// Cache user info to avoid repeated property access
const userName = user?.name || user?.email || 'Unknown User';
const userAvatar = user?.avatarUrl || undefined;
// Single iteration with optimized object creation
for (let i = 0; i < messageCount; i++) {
const msg = messages[i];
if (!msg) continue; // Skip if somehow undefined
const responseMessages = buildResponseMessages(msg.responseMessages);
const reasoningMessages = buildReasoningMessages(msg.reasoning);
const responseMessages = buildResponseMessages(msg.message.responseMessages);
const reasoningMessages = buildReasoningMessages(msg.message.reasoning);
// Pre-compute arrays to avoid Object.keys() calls
const responseMessageIds = Object.keys(responseMessages);
const reasoningMessageIds = Object.keys(reasoningMessages);
const requestMessage = msg.requestMessage
const requestMessage = msg.message.requestMessage
? {
request: msg.requestMessage,
sender_id: msg.createdBy,
sender_name: userName,
sender_avatar: userAvatar,
request: msg.message.requestMessage,
sender_id: msg.message.createdBy,
sender_name: msg.user.name || msg.user.email || 'Unknown User',
sender_avatar: msg.user.avatarUrl,
}
: null;
const chatMessage: ChatMessage = {
id: msg.id,
created_at: msg.createdAt,
updated_at: msg.updatedAt,
id: msg.message.id,
created_at: msg.message.createdAt,
updated_at: msg.message.updatedAt,
request_message: requestMessage,
response_messages: responseMessages,
response_message_ids: responseMessageIds,
reasoning_message_ids: reasoningMessageIds,
reasoning_messages: reasoningMessages,
final_reasoning_message: msg.finalReasoningMessage || null,
feedback: msg.feedback ? (msg.feedback as 'negative') : null,
is_completed: msg.isCompleted || false,
final_reasoning_message: msg.message.finalReasoningMessage || null,
feedback: msg.message.feedback ? (msg.message.feedback as 'negative') : null,
is_completed: msg.message.isCompleted || false,
post_processing_message: validateNullableJsonb(
msg.postProcessingMessage,
msg.message.postProcessingMessage,
PostProcessingMessageSchema
),
};
messageIds[i] = msg.id;
messageMap[msg.id] = chatMessage;
messageIds[i] = msg.message.id;
messageMap[msg.message.id] = chatMessage;
}
const [publiclyEnabledBy, individualPermissions, workspaceMemberCount] = await Promise.all([
getPubliclyEnabledByUser(chat.publiclyEnabledBy),
getUsersWithAssetPermissions({
assetId: chat.id,
assetType: 'chat',
}),
getOrganizationMemberCount(chat.organizationId),
]);
// Ensure message_ids array has no duplicates
const uniqueMessageIds = [...new Set(messageIds)];
@ -181,17 +197,16 @@ export function buildChatWithMessages(
updated_at: chat.updatedAt,
created_by: chat.createdBy,
created_by_id: chat.createdBy,
created_by_name: userName,
created_by_name: createdByName,
created_by_avatar: user?.avatarUrl || null,
// Sharing fields - TODO: implement proper sharing logic
individual_permissions: [],
individual_permissions: individualPermissions,
publicly_accessible: chat.publiclyAccessible || false,
public_expiry_date: chat.publicExpiryDate || null,
public_enabled_by: chat.publiclyEnabledBy || null,
public_password: null, // Don't expose password
permission: 'owner', // TODO: Implement proper permission checking
workspace_sharing: 'full_access',
workspace_member_count: 0,
public_enabled_by: publiclyEnabledBy,
public_password: null, // password not implemented yet
permission,
workspace_sharing: chat.workspaceSharing || 'none',
workspace_member_count: workspaceMemberCount,
};
}
@ -221,16 +236,22 @@ export async function handleExistingChat(
throw new ChatError(ChatErrorCode.CHAT_NOT_FOUND, 'Chat not found', 404);
}
const hasPermission = await canUserAccessChatCached({
const { effectiveRole, hasAccess } = await checkPermission({
userId: user.id,
chatId,
assetId: chatId,
assetType: 'chat',
requiredRole: 'can_view',
organizationId: chatDetails.chat.organizationId,
workspaceSharing: chatDetails.chat.workspaceSharing,
publiclyAccessible: chatDetails.chat.publiclyAccessible,
publicExpiryDate: chatDetails.chat.publicExpiryDate || undefined,
});
if (!hasAccess || !effectiveRole) {
throwUnauthorizedError({
publiclyAccessible: chatDetails.chat.publiclyAccessible,
publicExpiryDate: chatDetails.chat.publicExpiryDate || undefined,
});
if (!hasPermission) {
throw new ChatError(
ChatErrorCode.PERMISSION_DENIED,
'You do not have permission to access this chat',
403
);
}
// Handle redo logic if redoFromMessageId is provided
@ -266,17 +287,20 @@ export async function handleExistingChat(
metadata,
})
: Promise.resolve(null),
getMessagesForChat(chatId),
getMessagesForChatWithUserDetails(chatId),
]);
// Combine messages - prepend new message to maintain descending order (newest first)
const allMessages = newMessage ? [newMessage, ...existingMessages] : existingMessages;
const allMessages = newMessage
? [{ message: newMessage, user }, ...existingMessages]
: existingMessages;
// Build chat with messages
const chatWithMessages: ChatWithMessages = buildChatWithMessages(
const chatWithMessages: ChatWithMessages = await buildChatWithMessages(
chatDetails.chat,
allMessages,
chatDetails.user,
effectiveRole,
chatDetails.isFavorited
);
@ -378,10 +402,11 @@ export async function handleNewChat({
});
// Build chat with messages
const chatWithMessages = buildChatWithMessages(
const chatWithMessages = await buildChatWithMessages(
result.chat,
result.message ? [result.message] : [],
result.message ? [{ message: result.message, user }] : [],
user,
'owner',
false
);

View File

@ -3,14 +3,15 @@ import { ChatError, ChatErrorCode } from '@buster/server-shared/chats';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { initializeChat } from './chat-service';
import { canUserAccessChatCached } from '@buster/access-controls';
import { checkPermission } from '@buster/access-controls';
// Import mocked functions
import {
createChat,
createMessage,
generateAssetMessages,
getChatWithDetails,
getMessagesForChat,
getMessagesForChatWithUserDetails,
getOrganizationMemberCount,
getUsersWithAssetPermissions,
} from '@buster/database/queries';
const mockUser = {
@ -90,18 +91,34 @@ vi.mock('@buster/database/queries', () => ({
getChatWithDetails: vi.fn(),
createMessage: vi.fn(),
generateAssetMessages: vi.fn(),
getMessagesForChat: vi.fn(),
getMessagesForChatWithUserDetails: vi.fn(),
createAssetPermission: vi.fn(),
getUsersWithAssetPermissions: vi.fn(),
getOrganizationMemberCount: vi.fn(),
}));
// Mock access-controls
vi.mock('@buster/access-controls', () => ({
canUserAccessChatCached: vi.fn(),
checkPermission: vi.fn(),
}));
describe('chat-service', () => {
const mockCheckPermission = checkPermission as any;
const mockGetOrganizationMemberCount = getOrganizationMemberCount as any;
const mockGetUsersWithAssetPermissions = getUsersWithAssetPermissions as any;
const mockGetMessagesForChatWithUserDetails = getMessagesForChatWithUserDetails as any;
beforeEach(() => {
vi.clearAllMocks();
// Setup default mock returns
mockCheckPermission.mockResolvedValue({
hasAccess: true,
effectiveRole: 'can_view',
});
mockGetOrganizationMemberCount.mockResolvedValue(5);
mockGetUsersWithAssetPermissions.mockResolvedValue([]);
mockGetMessagesForChatWithUserDetails.mockResolvedValue([]);
});
describe('initializeChat', () => {
@ -117,13 +134,11 @@ describe('chat-service', () => {
});
it('should add message to existing chat when chat_id is provided', async () => {
vi.mocked(canUserAccessChatCached).mockResolvedValue(true);
vi.mocked(getChatWithDetails).mockResolvedValue({
chat: mockChat,
user: { id: 'user-123', name: 'Test User', avatarUrl: null } as any,
isFavorited: false,
});
vi.mocked(getMessagesForChat).mockResolvedValue([mockMessage]);
vi.mocked(createMessage).mockResolvedValue({
...mockMessage,
id: 'msg-456',
@ -136,9 +151,15 @@ describe('chat-service', () => {
'org-123'
);
expect(canUserAccessChatCached).toHaveBeenCalledWith({
expect(mockCheckPermission).toHaveBeenCalledWith({
userId: mockUser.id,
chatId: 'chat-123',
assetId: 'chat-123',
assetType: 'chat',
requiredRole: 'can_view',
organizationId: '550e8400-e29b-41d4-a716-446655440000',
publiclyAccessible: false,
publicExpiryDate: undefined,
workspaceSharing: undefined,
});
expect(createMessage).toHaveBeenCalledWith({
chatId: 'chat-123',
@ -150,7 +171,10 @@ describe('chat-service', () => {
});
it('should throw PERMISSION_DENIED error when user lacks permission', async () => {
vi.mocked(canUserAccessChatCached).mockResolvedValue(false);
mockCheckPermission.mockResolvedValue({
hasAccess: false,
effectiveRole: null,
});
vi.mocked(getChatWithDetails).mockResolvedValue({
chat: mockChat,
user: { id: 'user-123', name: 'Test User', avatarUrl: null } as any,
@ -159,14 +183,7 @@ describe('chat-service', () => {
await expect(
initializeChat({ chat_id: 'chat-123', prompt: 'Hello' }, mockUser, 'org-123')
).rejects.toThrow(ChatError);
await expect(
initializeChat({ chat_id: 'chat-123', prompt: 'Hello' }, mockUser, 'org-123')
).rejects.toMatchObject({
code: ChatErrorCode.PERMISSION_DENIED,
statusCode: 403,
});
).rejects.toThrow('You do not have permission to access this asset');
});
it('should throw CHAT_NOT_FOUND error when chat does not exist', async () => {

View File

@ -4,7 +4,7 @@ import {
getCollectionsAssociatedWithDashboard,
getDashboardById,
getOrganizationMemberCount,
getUsersWithDashboardPermissions,
getUsersWithAssetPermissions,
} from '@buster/database/queries';
import { DEFAULT_CHART_CONFIG } from '@buster/server-shared/metrics';
import { HTTPException } from 'hono/http-exception';
@ -25,7 +25,7 @@ vi.mock('@buster/access-controls', () => ({
vi.mock('@buster/database/queries', () => ({
getDashboardById: vi.fn(),
getUsersWithDashboardPermissions: vi.fn(),
getUsersWithAssetPermissions: vi.fn(),
getOrganizationMemberCount: vi.fn(),
getCollectionsAssociatedWithDashboard: vi.fn(),
}));
@ -49,7 +49,7 @@ vi.mock('js-yaml', () => ({
describe('getDashboardHandler', () => {
const mockCheckPermission = checkPermission as Mock;
const mockGetDashboardById = getDashboardById as Mock;
const mockGetUsersWithDashboardPermissions = getUsersWithDashboardPermissions as Mock;
const mockGetUsersWithAssetPermissions = getUsersWithAssetPermissions as Mock;
const mockGetOrganizationMemberCount = getOrganizationMemberCount as Mock;
const mockGetCollectionsAssociatedWithDashboard = getCollectionsAssociatedWithDashboard as Mock;
const mockGetPubliclyEnabledByUser = getPubliclyEnabledByUser as Mock;
@ -107,7 +107,7 @@ describe('getDashboardHandler', () => {
hasAccess: true,
effectiveRole: 'can_view',
});
mockGetUsersWithDashboardPermissions.mockResolvedValue([]);
mockGetUsersWithAssetPermissions.mockResolvedValue([]);
mockGetOrganizationMemberCount.mockResolvedValue(5);
mockGetCollectionsAssociatedWithDashboard.mockResolvedValue([]);
mockGetPubliclyEnabledByUser.mockResolvedValue(null);

View File

@ -4,7 +4,7 @@ import {
getCollectionsAssociatedWithDashboard,
getDashboardById,
getOrganizationMemberCount,
getUsersWithDashboardPermissions,
getUsersWithAssetPermissions,
} from '@buster/database/queries';
import {
GetDashboardParamsSchema,
@ -205,7 +205,7 @@ export async function getDashboardHandler(
// Get the extra dashboard info concurrently
const [individualPermissions, workspaceMemberCount, collections, publicEnabledBy] =
await Promise.all([
getUsersWithDashboardPermissions({ dashboardId }),
getUsersWithAssetPermissions({ assetId: dashboardId, assetType: 'dashboard_file' }),
getOrganizationMemberCount(dashboardFile.organizationId),
getCollectionsAssociatedWithDashboard(dashboardId, user.id),
getPubliclyEnabledByUser(dashboardFile.publiclyEnabledBy),

View File

@ -5,7 +5,7 @@ import {
getAssetsAssociatedWithMetric,
getMetricFileById,
getOrganizationMemberCount,
getUsersWithMetricPermissions,
getUsersWithAssetPermissions,
} from '@buster/database/queries';
import { type ChartConfigProps, DEFAULT_CHART_CONFIG } from '@buster/server-shared/metrics';
import { HTTPException } from 'hono/http-exception';
@ -26,7 +26,7 @@ vi.mock('@buster/access-controls', () => ({
vi.mock('@buster/database/queries', () => ({
getMetricFileById: vi.fn(),
getUsersWithMetricPermissions: vi.fn(),
getUsersWithAssetPermissions: vi.fn(),
getOrganizationMemberCount: vi.fn(),
getAssetsAssociatedWithMetric: vi.fn(),
}));
@ -45,7 +45,7 @@ vi.mock('js-yaml', () => ({
describe('metric-helpers', () => {
const mockCheckPermission = checkPermission as Mock;
const mockGetMetricFileById = getMetricFileById as Mock;
const mockGetUsersWithMetricPermissions = getUsersWithMetricPermissions as Mock;
const mockGetUsersWithAssetPermissions = getUsersWithAssetPermissions as Mock;
const mockGetOrganizationMemberCount = getOrganizationMemberCount as Mock;
const mockGetAssetsAssociatedWithMetric = getAssetsAssociatedWithMetric as Mock;
const mockGetPubliclyEnabledByUser = getPubliclyEnabledByUser as Mock;
@ -111,7 +111,7 @@ describe('metric-helpers', () => {
hasAccess: true,
effectiveRole: 'can_view',
});
mockGetUsersWithMetricPermissions.mockResolvedValue([]);
mockGetUsersWithAssetPermissions.mockResolvedValue([]);
mockGetOrganizationMemberCount.mockResolvedValue(5);
mockGetAssetsAssociatedWithMetric.mockResolvedValue({
dashboards: [],
@ -630,9 +630,9 @@ describe('metric-helpers', () => {
it('should include all associated data from concurrent queries', async () => {
const processedData = createProcessedData();
mockGetUsersWithMetricPermissions.mockResolvedValue([
{ userId: 'user-1', role: 'can_view' },
{ userId: 'user-2', role: 'can_edit' },
mockGetUsersWithAssetPermissions.mockResolvedValue([
{ role: 'can_view', email: 'user1@test.com', name: 'User 1', avatarUrl: null },
{ role: 'can_edit', email: 'user2@test.com', name: 'User 2', avatarUrl: null },
]);
mockGetOrganizationMemberCount.mockResolvedValue(10);
mockGetAssetsAssociatedWithMetric.mockResolvedValue({

View File

@ -5,7 +5,7 @@ import {
getAssetsAssociatedWithMetric,
getMetricFileById,
getOrganizationMemberCount,
getUsersWithMetricPermissions,
getUsersWithAssetPermissions,
} from '@buster/database/queries';
import {
type ChartConfigProps,
@ -215,7 +215,7 @@ export async function buildMetricResponse(
// Get the extra metric info concurrently
const [individualPermissions, workspaceMemberCount, associatedAssets, publicEnabledBy] =
await Promise.all([
getUsersWithMetricPermissions({ metricId: metricFile.id }),
getUsersWithAssetPermissions({ assetId: metricFile.id, assetType: 'metric_file' }),
getOrganizationMemberCount(metricFile.organizationId),
getAssetsAssociatedWithMetric(metricFile.id, userId),
getPubliclyEnabledByUser(metricFile.publiclyEnabledBy),

View File

@ -31,7 +31,7 @@ export const getListLogs = async (params?: GetLogsListRequest): Promise<GetLogsL
};
export const getChat = async ({ id }: GetChatRequest): Promise<GetChatResponse> => {
return mainApi.get<GetChatResponse>(`${CHATS_BASE}/${id}`).then((res) => res.data);
return mainApiV2.get<GetChatResponse>(`${CHATS_BASE}/${id}`).then((res) => res.data);
};
export const deleteChat = async (data: DeleteChatsRequest): Promise<void> => {

View File

@ -311,6 +311,88 @@ export async function checkChatCollectionAccess(chatId: string, user: User): Pro
}
}
/**
* Check if a user has access to a report through any chat that contains it.
* If a user has access to a chat (direct, public, or workspace), they can view the reports in it.
*/
export async function checkReportChatAccess(reportId: string, user: User): Promise<boolean> {
try {
// Get all chats containing this dashboard with their workspace sharing info
const chats = await checkChatsContainingAsset(reportId, 'report_file');
if (!chats || chats.length === 0) {
return false;
}
// Check if user has access to any of these chats
for (const chat of chats) {
const hasAccess = await hasAssetPermission({
assetId: chat.id,
assetType: 'chat' as AssetType,
userId: user.id,
requiredRole: 'can_view' as AssetPermissionRole,
organizationId: chat.organizationId,
workspaceSharing: (chat.workspaceSharing as WorkspaceSharing) ?? 'none',
publiclyAccessible: chat.publiclyAccessible,
publicExpiryDate: chat.publicExpiryDate ?? undefined,
publicPassword: undefined, // We don't support passwords on the chats table
userSuppliedPassword: undefined, // We don't support passwords on the chats table
});
if (hasAccess) {
return true;
}
}
return false;
} catch (error) {
throw new AccessControlError(
'cascading_permission_error',
'Failed to check report chat access',
{ error }
);
}
}
/**
* Check if a user has access to a report through any collection that contains it.
* If a user has access to a collection (direct or workspace), they can view the reports in it.
*/
export async function checkReportCollectionAccess(reportId: string, user: User): Promise<boolean> {
try {
// Get all collections containing this report with their workspace sharing info
const collections = await checkCollectionsContainingAsset(reportId, 'report_file');
if (!collections || collections.length === 0) {
return false;
}
// Check if user has access to any of these collections
for (const collection of collections) {
const hasAccess = await hasAssetPermission({
assetId: collection.id,
assetType: 'collection' as AssetType,
userId: user.id,
requiredRole: 'can_view' as AssetPermissionRole,
organizationId: collection.organizationId,
workspaceSharing: (collection.workspaceSharing as WorkspaceSharing) ?? 'none',
});
if (hasAccess) {
return true;
}
}
return false;
} catch (error) {
throw new AccessControlError(
'cascading_permission_error',
'Failed to check report collection access',
{ error }
);
}
}
/**
* Check cascading permissions for an asset.
* This checks if a user has access to an asset through other assets that contain it.
@ -389,12 +471,26 @@ export async function checkCascadingPermissions(
break;
}
case 'report_file': {
// Check access through chats and collections
const reportChatAccess = await checkReportChatAccess(assetId, user);
if (reportChatAccess) {
hasAccess = true;
break;
}
const reportCollectionAccess = await checkReportCollectionAccess(assetId, user);
if (reportCollectionAccess) {
hasAccess = true;
break;
}
break;
}
case 'collection':
case 'report_file':
// Collections and reports don't have cascading permissions (they're top-level)
hasAccess = false;
break;
// Collections don't have cascading permissions (they're top-level)
default:
hasAccess = false;
}

View File

@ -113,8 +113,6 @@ export async function checkPermission(check: AssetPermissionCheck): Promise<Asse
}
}
console.info('publiclyAccessible', publiclyAccessible);
if (publiclyAccessible) {
const hasPublicAccessCheck = hasPublicAccess(
publiclyAccessible,

View File

@ -420,28 +420,47 @@ You operate in a loop to complete tasks:
- Number cards should always have a metricHeader and metricSubheader.
- Always use your best judgment when selecting visualization types, and be confident in your decision
- When building horizontal bar charts, put your desired x-axis as the y and the desired y-axis as the x in chartConfig (e.g. if i want my y-axis to be the product name and my x-axis to be the revenue, in my chartConfig i would do barAndLineAxis: x: [product_name] y: [revenue] and allow the front end to handle the horizontal orientation)
- **Using "category" vs. "colorBy" Guidelines**
- When adding dimensions to a bar or line chart, carefully decide whether to use `category` or `colorBy`.
- **Use `category` when you want to create separate series or groups of values.**
- Each category value generates its own line, bar group, or stacked section.
- Examples:
- Line chart with revenue by month split by region → `category: [region]`
- Grouped bar chart of sales by product split by channel → `category: [channel]`
- Rule of thumb: use `category` when the visualization should **separate and compare multiple data series**.
- **Use `colorBy` when you want to keep a single series (no grouping, stacking, etc) but visually differentiate elements by color.**
- Bars or lines remain part of one series, but the colors vary by the field.
- Examples:
- Bar chart of sales by sales rep, with bars colored by region → `colorBy: [region]`
- Line chart of monthly revenue, with points/segments colored by product line → `colorBy: [product_line]`
- Rule of thumb: use `colorBy` when the visualization should **highlight categories inside a single series** rather than split into multiple groups.
- **Guidance by chart type**:
- Bar/Line:
- `category` → multiple series (grouped/stacked bars, multiple lines).
- `colorBy` → single series, colored by attribute.
- **Quick heuristic**:
- Ask: “Does the user want multiple grouped/stacked series, or just one series with colored differentiation?”
- Multiple → `category`
- One series, just colored → `colorBy`
- Using a categorical field as "category" vs. "colorBy"
- Critical Clarification:
- Many fields are categorical (text, labels, enums), but this does **not** mean they belong in `category`.
- `category` in chartConfig has a very specific meaning: *split into multiple parallel series*.
- Most categorical-looking fields should instead be used in `colorBy`, not in `category`.
- Decision Rule
- Ask: *Is this field defining the primary series, or just adding context?*
- Primary series split → use category
- Example: *Revenue over time by region* → multiple lines (category = region).
- Context only → use colorBy
- Example: *Sales reps performance colored by region* → one bar per rep (colorBy = region).
- No secondary grouping needed → use neither
- Example: *Top 10 products by sales* → one bar per product, no colorBy.
- Checklist Before Using category
1. Is the X-axis a time series and the user wants multiple lines/bars? → `category`.
2. Does the chart need parallel groups of the same measure (e.g., stacked/grouped bars)? → `category`.
3. Otherwise (entity list on X, one measure on Y) → keep single series, use `colorBy` if needed.
- Common Confusion Traps
- Fields like `region`, `segment`, `department`, `status`, `role`, etc. *look categorical* but usually belong in `colorBy` when the main X is an entity list (e.g., reps, products).
- Do **not** force `category` just because the field name sounds like a grouping.
- Example: *Compare East vs West reps* → Wrong = `category = region` (duplicates reps). Correct = `colorBy = region`.
- Examples
- Correct — category
- "Show monthly revenue by region" → X = month, Y = revenue, `category = region` → multiple lines.
- "Stacked bars of sales by product category" → X = category, Y = sales, `category = product_type`.
- Correct — colorBy
- "Compare individual customers and their revenue from East vs West" → X = rep, Y = sales, `colorBy = region` → one bar per rep, East as one color/West as another color.
- "Quota attainment by department" → X = rep, Y = quota %, `colorBy = department`.
- Correct — neither
- "Top 10 products by revenue" → X = product, Y = revenue, no `category` or `colorBy`.
- "Monthly revenue trend" → X = month, Y = revenue, single line, no `category` or `colorBy`.
- Incorrect — misuse of category
- Wrong: "Compare East vs West reps" → X = rep, Y = sales, `category = region` (creates duplicate reps, confusing grouped bars).
- Wrong: "Product sales by category" when only one measure → `category = product_type` (splits into parallel series unnecessarily).
- Safeguards
- Do **not** automatically map categorical fields to `category`.
- Use **either** `category` or `colorBy`, never both.
- “Comparison” or “versus” in user wording does not imply multiple series. If the main breakdown is still a single list, use `colorBy`.
- Rule of Thumb for Categorical Fields
- *If the request is centered on comparing items in a single list → use colorBy.*
- *If the request is centered on comparing groups as separate series → use category.*
...
- Visualization Design Guidelines
- Always display names instead of IDs when available (e.g., "Product Name" instead of "Product ID")
@ -493,6 +512,9 @@ You operate in a loop to complete tasks:
- For monthly trends across years: barAndLineAxis: { x: [month, year], y: [...] }
- For quarterly trends: barAndLineAxis: { x: [quarter, year], y: [...] }
- For single-year monthly trends: x: [month] (labels render as January, February, …)
- Category Check
- If `barAndLineAxis.x` or `comboChartAxis.x` contains a single non-time dimension (e.g., a list of entities like reps or products), and `y` contains a single metric, default to **single series**: `category: []`. Use `colorBy` for any secondary attribute if needed.
- If `x` is a **time axis** and the requirement is to compare groups **as separate series** over time, then use `category: ['<group_field>']`.
</visualization_and_charting_guidelines>
<when_to_create_new_metric_vs_update_exsting_metric>
@ -508,8 +530,9 @@ You operate in a loop to complete tasks:
- The system is read-only and you cannot write to databases.
- Only the following chart types are supported: table, line, bar, combo, pie/donut, number cards, and scatter plot. Other chart types are not supported.
- You cannot write Python.
- You cannot highlight or flag specific elements (e.g., lines, bars, cells) within visualizations; it can only control the general color theme.
- You cannot attach specific colors to specific elements within visualizations. Only general color themes are supported.
- You cannot assign custom hex colors to specific individual elements (e.g., hard-map “East = #123456”). Only default palettes are supported.
- You cannot “spot highlight” arbitrary single bars/points by ID.
- **`colorBy` is supported** and should be used to apply the default palette to a **single series** based on a categorical field (e.g., color bars by `region` without creating multiple series).
- Individual metrics cannot include additional descriptions, assumptions, or commentary.
- Dashboard layout constraints:
- Dashboards display collections of existing metrics referenced by their IDs.

View File

@ -765,28 +765,23 @@ If all true → proceed to submit prep for Asset Creation with `submitThoughts`.
- if you are building a table of customers, the first column should be their name.
- If you are building a table comparing regions, have the first column be region.
- If you are building a column comparing regions but each row is a customer, have the first column be customer name and the second be the region but have it ordered by region so customers of the same region are next to each other.
- **Using "category" vs. "colorBy" Guidelines**
- When adding dimensions to a bar or line chart, carefully decide whether to use `category` or `colorBy`.
- **Use `category` when you want to create separate series or groups of values.**
- Each category value generates its own line, bar group, or stacked section.
- Examples:
- Line chart with revenue by month split by region → `category: [region]`
- Grouped bar chart of sales by product split by channel → `category: [channel]`
- Rule of thumb: use `category` when the visualization should **separate and compare multiple data series**.
- **Use `colorBy` when you want to keep a single series (no grouping, stacking, etc) but visually differentiate elements by color.**
- Bars or lines remain part of one series, but the colors vary by the field.
- Examples:
- Bar chart of sales by sales rep, with bars colored by region → `colorBy: [region]`
- Line chart of monthly revenue, with points/segments colored by product line → `colorBy: [product_line]`
- Rule of thumb: use `colorBy` when the visualization should **highlight categories inside a single series** rather than split into multiple groups.
- **Guidance by chart type**:
- Bar/Line:
- `category` → multiple series (grouped/stacked bars, multiple lines).
- `colorBy` → single series, colored by attribute.
- **Quick heuristic**:
- Ask: “Does the user want multiple grouped/stacked series, or just one series with colored differentiation?”
- Multiple → `category`
- One series, just colored → `colorBy`
- Using a category as "series grouping" vs. "color grouping" (categories/grouping rules for bar and line charts)
- Many attributes are categorical (labels, enums), but this does **not** mean they should create multiple series.
- Series grouping has a very specific meaning: *split into multiple parallel series that align across the X-axis*.
- Color grouping assigns colors within a single series and **does not** create parallel series.
- Misusing series grouping to “separate colors” causes empty slots or duplicated labels when categories dont exist for every item/time — resulting in a janky chart with gaps.
- Decision Rule
- Ask: *Is this category defining the primary comparison structure, or just distinguishing items?*
- Primary structure split → use series grouping
- Example: *Values over time by group* → multiple lines (one per group).
- Distinguishing only → use color grouping
- Example: *Items on one axis, colored by group* → one bar/line per item, colored by group.
- No secondary distinction needed → use neither
- Example: *Top N items by value* → one bar per item, no color grouping.
- Checklist Before Using series grouping
1. Is the X-axis temporal and the intent is to compare multiple parallel trends? → series grouping.
2. Do you need grouped/stacked comparisons of the **same** measure across multiple categories? → series grouping.
3. Otherwise (entity list on X with a single measure on Y) → keep a single series; no category/color grouping needed.
- Planning and Description Guidelines
- For grouped/stacked bar charts, specify the grouping/stacking field (e.g., "grouped by `[field_name]`").
- For bar charts with time units (e.g., days of the week, months, quarters, years) on the x-axis, sort the bars in chronological order rather than in ascending or descending order based on the y-axis measure.

View File

@ -619,28 +619,23 @@ When in doubt, be more thorough rather than less. Reports are the default becaus
- if you are building a table of customers, the first column should be their name.
- If you are building a table comparing regions, have the first column be region.
- If you are building a column comparing regions but each row is a customer, have the first column be customer name and the second be the region but have it ordered by region so customers of the same region are next to each other.
- **Using "category" vs. "colorBy" Guidelines**
- When adding dimensions to a bar or line chart, carefully decide whether to use `category` or `colorBy`.
- **Use `category` when you want to create separate series or groups of values.**
- Each category value generates its own line, bar group, or stacked section.
- Examples:
- Line chart with revenue by month split by region → `category: [region]`
- Grouped bar chart of sales by product split by channel → `category: [channel]`
- Rule of thumb: use `category` when the visualization should **separate and compare multiple data series**.
- **Use `colorBy` when you want to keep a single series (no grouping, stacking, etc) but visually differentiate elements by color.**
- Bars or lines remain part of one series, but the colors vary by the field.
- Examples:
- Bar chart of sales by sales rep, with bars colored by region → `colorBy: [region]`
- Line chart of monthly revenue, with points/segments colored by product line → `colorBy: [product_line]`
- Rule of thumb: use `colorBy` when the visualization should **highlight categories inside a single series** rather than split into multiple groups.
- **Guidance by chart type**:
- Bar/Line:
- `category` → multiple series (grouped/stacked bars, multiple lines).
- `colorBy` → single series, colored by attribute.
- **Quick heuristic**:
- Ask: “Does the user want multiple grouped/stacked series, or just one series with colored differentiation?”
- Multiple → `category`
- One series, just colored → `colorBy`
- Using a category as "series grouping" vs. "color grouping" (categories/grouping rules for bar and line charts)
- Many attributes are categorical (labels, enums), but this does **not** mean they should create multiple series.
- Series grouping has a very specific meaning: *split into multiple parallel series that align across the X-axis*.
- Color grouping assigns colors within a single series and **does not** create parallel series.
- Misusing series grouping to “separate colors” causes empty slots or duplicated labels when categories dont exist for every item/time — resulting in a janky chart with gaps.
- Decision Rule
- Ask: *Is this category defining the primary comparison structure, or just distinguishing items?*
- Primary structure split → use series grouping
- Example: *Values over time by group* → multiple lines (one per group).
- Distinguishing only → use color grouping
- Example: *Items on one axis, colored by group* → one bar/line per item, colored by group.
- No secondary distinction needed → use neither
- Example: *Top N items by value* → one bar per item, no color grouping.
- Checklist Before Using series grouping
1. Is the X-axis temporal and the intent is to compare multiple parallel trends? → series grouping.
2. Do you need grouped/stacked comparisons of the **same** measure across multiple categories? → series grouping.
3. Otherwise (entity list on X with a single measure on Y) → keep a single series; no category/color grouping needed.
- Planning and Description Guidelines
- For grouped/stacked bar charts, specify the grouping/stacking field (e.g., "grouped by `[field_name]`").
- For bar charts with time units (e.g., days of the week, months, quarters, years) on the x-axis, sort the bars in chronological order rather than in ascending or descending order based on the y-axis measure.

View File

@ -2,7 +2,44 @@ import { createGateway } from '@ai-sdk/gateway';
import { wrapLanguageModel } from 'ai';
import { BraintrustMiddleware } from 'braintrust';
export const DEFAULT_ANTHROPIC_OPTIONS = {
// Provider-specific option types
export type GatewayProviderOrder = string[];
export type AnthropicOptions = {
cacheControl?: { type: 'ephemeral' };
};
export type BedrockOptions = {
cachePoint?: { type: 'default' };
additionalModelRequestFields?: {
anthropic_beta?: string[];
};
};
export type OpenAIOptions = {
// parallelToolCalls?: boolean;
reasoningEffort?: 'low' | 'medium' | 'high' | 'minimal';
verbosity?: 'low' | 'medium' | 'high';
};
// Main provider options types
export type AnthropicProviderOptions = {
gateway: {
order: GatewayProviderOrder;
};
anthropic: AnthropicOptions;
bedrock: BedrockOptions;
};
export type OpenAIProviderOptions = {
gateway: {
order: GatewayProviderOrder;
};
openai: OpenAIOptions;
};
// Default options with proper typing
export const DEFAULT_ANTHROPIC_OPTIONS: AnthropicProviderOptions = {
gateway: {
order: ['bedrock', 'anthropic', 'vertex'],
},
@ -10,14 +47,14 @@ export const DEFAULT_ANTHROPIC_OPTIONS = {
cacheControl: { type: 'ephemeral' },
},
bedrock: {
cacheControl: { type: 'ephemeral' },
cachePoint: { type: 'default' },
additionalModelRequestFields: {
anthropic_beta: ['fine-grained-tool-streaming-2025-05-14'],
},
},
};
export const DEFAULT_OPENAI_OPTIONS = {
export const DEFAULT_OPENAI_OPTIONS: OpenAIProviderOptions = {
gateway: {
order: ['openai'],
},

View File

@ -75,11 +75,13 @@ export function createDoneToolExecute(context: DoneToolContext, state: DoneToolS
throw new Error('Tool call ID is required');
}
const result = await processDone(state, state.toolCallId, context.messageId, context, input);
// Wait for all pending updates from delta/finish to complete before returning
// CRITICAL: Wait for ALL pending updates from delta/finish to complete FIRST
// This ensures execute's update is always the last one in the queue
await waitForPendingUpdates(context.messageId);
// Now do the final authoritative update with the complete input
const result = await processDone(state, state.toolCallId, context.messageId, context, input);
cleanupState(state);
return result;
},

View File

@ -0,0 +1,60 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { assetPermissions, users } from '../../schema';
import { AssetPermissionRoleSchema, AssetTypeSchema, IdentityTypeSchema } from '../../schema-types';
export const GetUsersWithAssetPermissionsInputSchema = z.object({
assetId: z.string().uuid(),
assetType: AssetTypeSchema,
});
export type GetUsersWithAssetPermissionsInput = z.infer<
typeof GetUsersWithAssetPermissionsInputSchema
>;
export const GetUsersWithAssetPermissionsResultSchema = z.object({
role: AssetPermissionRoleSchema,
email: z.string(),
name: z.string().nullable(),
avatarUrl: z.string().nullable(),
});
export type GetUsersWithAssetPermissionsResult = z.infer<
typeof GetUsersWithAssetPermissionsResultSchema
>;
/**
* Get all users with direct permissions to any asset type
* This is a generic function that works with chats, metrics, dashboards, etc.
*/
export async function getUsersWithAssetPermissions(
input: GetUsersWithAssetPermissionsInput
): Promise<GetUsersWithAssetPermissionsResult[]> {
const validated = GetUsersWithAssetPermissionsInputSchema.parse(input);
const individualPermissions = await db
.select({
role: assetPermissions.role,
email: users.email,
name: users.name,
avatarUrl: users.avatarUrl,
})
.from(assetPermissions)
.innerJoin(users, eq(users.id, assetPermissions.identityId))
.where(
and(
eq(assetPermissions.assetId, validated.assetId),
eq(assetPermissions.assetType, validated.assetType),
eq(assetPermissions.identityType, 'user'),
isNull(assetPermissions.deletedAt)
)
);
return individualPermissions.map((row) => ({
role: row.role,
email: row.email,
name: row.name,
avatarUrl: row.avatarUrl,
}));
}

View File

@ -29,3 +29,10 @@ export {
GetAssetLatestVersionInputSchema,
type GetAssetLatestVersionInput,
} from './get-asset-latest-version';
export {
getUsersWithAssetPermissions,
GetUsersWithAssetPermissionsInputSchema,
type GetUsersWithAssetPermissionsInput,
type GetUsersWithAssetPermissionsResult,
} from './get-users-with-asset-permissions';

View File

@ -13,7 +13,7 @@ export interface ChatWithSharing {
export async function checkChatsContainingAsset(
assetId: string,
_assetType: 'metric_file' | 'dashboard_file'
_assetType: 'metric_file' | 'dashboard_file' | 'report_file'
): Promise<ChatWithSharing[]> {
const result = await db
.selectDistinct({

View File

@ -1,58 +0,0 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { assetPermissions, users } from '../../schema';
import { AssetPermissionRoleSchema, type AssetType, type IdentityType } from '../../schema-types';
export const GetUsersWithDashboardPermissionsInputSchema = z.object({
dashboardId: z.string().uuid(),
});
export type GetUsersWithDashboardPermissionsInput = z.infer<
typeof GetUsersWithDashboardPermissionsInputSchema
>;
export const GetUsersWithDashboardPermissionsResultSchema = z.object({
role: AssetPermissionRoleSchema,
email: z.string(),
name: z.string().nullable(),
avatarUrl: z.string().nullable(),
});
export type GetUsersWithDashboardPermissionsResult = z.infer<
typeof GetUsersWithDashboardPermissionsResultSchema
>;
/**
* Get all users with direct permissions to a dashboard
*/
export async function getUsersWithDashboardPermissions(
input: GetUsersWithDashboardPermissionsInput
): Promise<GetUsersWithDashboardPermissionsResult[]> {
const validated = GetUsersWithDashboardPermissionsInputSchema.parse(input);
const individualPermissions = await db
.select({
role: assetPermissions.role,
email: users.email,
name: users.name,
avatarUrl: users.avatarUrl,
})
.from(assetPermissions)
.innerJoin(users, eq(users.id, assetPermissions.identityId))
.where(
and(
eq(assetPermissions.assetId, validated.dashboardId),
eq(assetPermissions.assetType, 'dashboard_file' as AssetType),
eq(assetPermissions.identityType, 'user' as IdentityType),
isNull(assetPermissions.deletedAt)
)
);
return individualPermissions.map((row) => ({
role: row.role,
email: row.email,
name: row.name,
avatarUrl: row.avatarUrl,
}));
}

View File

@ -16,13 +16,6 @@ export {
type GetDashboardByIdInput,
} from './get-dashboard-by-id';
export {
getUsersWithDashboardPermissions,
GetUsersWithDashboardPermissionsInputSchema,
type GetUsersWithDashboardPermissionsInput,
type GetUsersWithDashboardPermissionsResult,
} from './get-users-with-dashboard-permissions-by-id';
export {
getCollectionsAssociatedWithDashboard,
type AssociatedCollection,

View File

@ -1,7 +1,7 @@
import type { InferSelectModel } from 'drizzle-orm';
import { and, desc, eq, isNull, ne } from 'drizzle-orm';
import { db } from '../../connection';
import { messages } from '../../schema';
import { messages, users } from '../../schema';
export type Message = InferSelectModel<typeof messages>;
@ -40,6 +40,20 @@ export async function getMessagesForChat(chatId: string) {
.orderBy(desc(messages.createdAt));
}
/**
* Get all messages for a specific chat with user details
* @param chatId - The ID of the chat
* @returns Array of messages for the chat with user details
*/
export async function getMessagesForChatWithUserDetails(chatId: string) {
return await db
.select({ message: messages, user: users })
.from(messages)
.innerJoin(users, eq(messages.createdBy, users.id))
.where(and(eq(messages.chatId, chatId), isNull(messages.deletedAt)))
.orderBy(desc(messages.createdAt));
}
/**
* Get the latest message for a specific chat
* @param chatId - The ID of the chat

View File

@ -1,58 +0,0 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { assetPermissions, users } from '../../schema';
import { AssetPermissionRoleSchema, type AssetType, type IdentityType } from '../../schema-types';
export const GetUsersWithMetricPermissionsInputSchema = z.object({
metricId: z.string().uuid(),
});
export type GetUsersWithMetricPermissionsInput = z.infer<
typeof GetUsersWithMetricPermissionsInputSchema
>;
export const GetUsersWithMetricPermissionsResultSchema = z.object({
role: AssetPermissionRoleSchema,
email: z.string(),
name: z.string().nullable(),
avatarUrl: z.string().nullable(),
});
export type GetUsersWithMetricPermissionsResult = z.infer<
typeof GetUsersWithMetricPermissionsResultSchema
>;
/**
* Get all users with direct permissions to a metric
*/
export async function getUsersWithMetricPermissions(
input: GetUsersWithMetricPermissionsInput
): Promise<GetUsersWithMetricPermissionsResult[]> {
const validated = GetUsersWithMetricPermissionsInputSchema.parse(input);
const individualPermissions = await db
.select({
role: assetPermissions.role,
email: users.email,
name: users.name,
avatarUrl: users.avatarUrl,
})
.from(assetPermissions)
.innerJoin(users, eq(users.id, assetPermissions.identityId))
.where(
and(
eq(assetPermissions.assetId, validated.metricId),
eq(assetPermissions.assetType, 'metric_file' as AssetType),
eq(assetPermissions.identityType, 'user' as IdentityType),
isNull(assetPermissions.deletedAt)
)
);
return individualPermissions.map((row) => ({
role: row.role,
email: row.email,
name: row.name,
avatarUrl: row.avatarUrl,
}));
}

View File

@ -39,11 +39,3 @@ export {
type AssociatedAsset,
type AssetsAssociatedWithMetric,
} from './get-permissioned-asset-associations';
export {
getUsersWithMetricPermissions,
GetUsersWithMetricPermissionsInputSchema,
GetUsersWithMetricPermissionsResultSchema,
type GetUsersWithMetricPermissionsInput,
type GetUsersWithMetricPermissionsResult,
} from './get-users-with-metric-permissions-by-id';