migrating /chats/[id] GET endpoint to v2

This commit is contained in:
Wells Bunker 2025-09-29 11:02:56 -06:00
parent b3473fdd0d
commit 09030d5d2b
No known key found for this signature in database
GPG Key ID: DB16D6F2679B78FC
21 changed files with 403 additions and 216 deletions

View File

@ -0,0 +1,87 @@
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;
console.info('user', user);
// 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 (!hasPermission) {
throw new ChatError(
ChatErrorCode.PERMISSION_DENIED,
'You do not have permission to access this chat',
403
);
if (!hasAccess || !effectiveRole) {
throwUnauthorizedError({
publiclyAccessible: chatDetails.chat.publiclyAccessible,
publicExpiryDate: chatDetails.chat.publicExpiryDate || undefined,
});
}
// 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 { canUserAccessChatCached, 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', () => {
@ -136,9 +153,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 +173,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 +185,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

@ -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';

View File

@ -154,6 +154,8 @@ export const ChatMessageSchema = z.object({
post_processing_message: PostProcessingMessageSchema.optional(),
});
export type ReasoningMessage = z.infer<typeof ReasoningMessageSchema>;
export type ResponseMessage = z.infer<typeof ResponseMessageSchema>;
export type MessageRole = z.infer<typeof MessageRoleSchema>;
export type ChatUserMessage = z.infer<typeof ChatUserMessageSchema>;
export type ChatMessage = z.infer<typeof ChatMessageSchema>;