mirror of https://github.com/buster-so/buster.git
Merge branch 'staging' into big-nate-bus-1830-ability-to-apply-color-theme-by-a-category
This commit is contained in:
commit
1ac26de837
|
@ -38,17 +38,6 @@ export async function getMetricDataHandler(
|
|||
versionNumber?: number,
|
||||
reportFileId?: string
|
||||
): Promise<MetricDataResponse> {
|
||||
// Get user's organization
|
||||
const userOrg = await getUserOrganizationId(user.id);
|
||||
|
||||
if (!userOrg) {
|
||||
throw new HTTPException(403, {
|
||||
message: 'You must be part of an organization to access metric data',
|
||||
});
|
||||
}
|
||||
|
||||
const { organizationId } = userOrg;
|
||||
|
||||
// Retrieve metric definition from database with data source info
|
||||
const metric = await getMetricWithDataSource({ metricId, versionNumber });
|
||||
|
||||
|
@ -58,13 +47,6 @@ export async function getMetricDataHandler(
|
|||
});
|
||||
}
|
||||
|
||||
// Verify metric belongs to user's organization
|
||||
if (metric.organizationId !== organizationId) {
|
||||
throw new HTTPException(403, {
|
||||
message: 'You do not have permission to view this metric',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has permission to view this metric file
|
||||
// hasAssetPermission internally handles:
|
||||
// 1. Direct permissions
|
||||
|
@ -76,7 +58,7 @@ export async function getMetricDataHandler(
|
|||
assetId: metricId,
|
||||
assetType: 'metric_file',
|
||||
requiredRole: 'can_view',
|
||||
organizationId,
|
||||
organizationId: metric.organizationId,
|
||||
workspaceSharing: metric.workspaceSharing ?? 'none',
|
||||
publiclyAccessible: metric.publiclyAccessible,
|
||||
publicExpiryDate: metric.publicExpiryDate ?? undefined,
|
||||
|
@ -98,13 +80,13 @@ export async function getMetricDataHandler(
|
|||
console.info('Checking R2 cache for metric data', {
|
||||
metricId,
|
||||
reportFileId,
|
||||
organizationId,
|
||||
organizationId: metric.organizationId,
|
||||
version: resolvedVersion,
|
||||
});
|
||||
|
||||
try {
|
||||
const cachedData = await getCachedMetricData(
|
||||
organizationId,
|
||||
metric.organizationId,
|
||||
metricId,
|
||||
reportFileId,
|
||||
resolvedVersion
|
||||
|
@ -184,22 +166,26 @@ export async function getMetricDataHandler(
|
|||
console.info('Writing metric data to cache', {
|
||||
metricId,
|
||||
reportFileId,
|
||||
organizationId,
|
||||
organizationId: metric.organizationId,
|
||||
version: resolvedVersion,
|
||||
rowCount: trimmedData.length,
|
||||
});
|
||||
|
||||
// Fire and forget - don't wait for cache write
|
||||
setCachedMetricData(organizationId, metricId, reportFileId, response, resolvedVersion).catch(
|
||||
(error) => {
|
||||
setCachedMetricData(
|
||||
metric.organizationId,
|
||||
metricId,
|
||||
reportFileId,
|
||||
response,
|
||||
resolvedVersion
|
||||
).catch((error) => {
|
||||
console.error('Failed to cache metric data', {
|
||||
metricId,
|
||||
reportFileId,
|
||||
version: resolvedVersion,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from '@buster/database/connection';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { findOrCreateSlackChat } from './events';
|
||||
import { eventsHandler, findOrCreateSlackChat } from './events';
|
||||
|
||||
vi.mock('@buster/database/connection', () => ({
|
||||
db: {
|
||||
|
@ -14,6 +14,31 @@ vi.mock('@buster/database/schema', () => ({
|
|||
slackIntegrations: {},
|
||||
}));
|
||||
|
||||
vi.mock('@buster/database/queries', () => ({
|
||||
getSecretByName: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@buster/slack', () => ({
|
||||
SlackMessagingService: vi.fn(() => ({
|
||||
sendMessage: vi.fn(),
|
||||
})),
|
||||
isEventCallback: vi.fn(),
|
||||
isAppMentionEvent: vi.fn(),
|
||||
isMessageImEvent: vi.fn(),
|
||||
addReaction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@trigger.dev/sdk', () => ({
|
||||
tasks: {
|
||||
trigger: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./services/slack-authentication', () => ({
|
||||
authenticateSlackUser: vi.fn(),
|
||||
getUserIdFromAuthResult: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('findOrCreateSlackChat', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
@ -221,3 +246,310 @@ describe('findOrCreateSlackChat', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventsHandler - Unauthorized Users', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return success without sending message for bot unauthorized users', async () => {
|
||||
const { isEventCallback, isAppMentionEvent, isMessageImEvent } = await import('@buster/slack');
|
||||
const { authenticateSlackUser } = await import('./services/slack-authentication');
|
||||
|
||||
// Mock event callback detection
|
||||
vi.mocked(isEventCallback).mockReturnValue(true);
|
||||
vi.mocked(isAppMentionEvent).mockReturnValue(true);
|
||||
vi.mocked(isMessageImEvent).mockReturnValue(false);
|
||||
|
||||
// Mock authentication to return unauthorized bot
|
||||
vi.mocked(authenticateSlackUser).mockResolvedValue({
|
||||
type: 'unauthorized',
|
||||
reason: 'User is a bot account',
|
||||
} as any);
|
||||
|
||||
const payload = {
|
||||
type: 'event_callback' as const,
|
||||
token: 'xoxb-test-token',
|
||||
team_id: 'T123456',
|
||||
api_app_id: 'A123456',
|
||||
event_id: 'E123456',
|
||||
event_time: 1234567890,
|
||||
event: {
|
||||
type: 'app_mention' as const,
|
||||
user: 'U123456',
|
||||
channel: 'C123456',
|
||||
text: 'Hello Buster',
|
||||
ts: '1234567890.123456',
|
||||
event_ts: '1234567890.123456',
|
||||
},
|
||||
};
|
||||
|
||||
const result = await eventsHandler(payload);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456');
|
||||
|
||||
// Should not attempt to send message or get access token
|
||||
expect(db.select).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should send unauthorized message for regular unauthorized users', async () => {
|
||||
const { isEventCallback, isAppMentionEvent, isMessageImEvent, SlackMessagingService } =
|
||||
await import('@buster/slack');
|
||||
const { authenticateSlackUser } = await import('./services/slack-authentication');
|
||||
const { getSecretByName } = await import('@buster/database/queries');
|
||||
|
||||
// Mock event callback detection
|
||||
vi.mocked(isEventCallback).mockReturnValue(true);
|
||||
vi.mocked(isAppMentionEvent).mockReturnValue(true);
|
||||
vi.mocked(isMessageImEvent).mockReturnValue(false);
|
||||
|
||||
// Mock authentication to return unauthorized (non-bot)
|
||||
vi.mocked(authenticateSlackUser).mockResolvedValue({
|
||||
type: 'unauthorized',
|
||||
reason: 'User email not found in organization domain',
|
||||
} as any);
|
||||
|
||||
// Mock database query for getting access token
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([{ tokenVaultKey: 'vault-key-123' }]),
|
||||
} as any);
|
||||
|
||||
// Mock vault secret
|
||||
vi.mocked(getSecretByName).mockResolvedValue({
|
||||
secret: 'xoxb-test-token',
|
||||
} as any);
|
||||
|
||||
// Mock messaging service
|
||||
const mockSendMessage = vi.fn().mockResolvedValue({ ok: true, ts: '1234567890.123456' });
|
||||
vi.mocked(SlackMessagingService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
sendMessage: mockSendMessage,
|
||||
}) as any
|
||||
);
|
||||
|
||||
const payload = {
|
||||
type: 'event_callback' as const,
|
||||
token: 'xoxb-test-token',
|
||||
team_id: 'T123456',
|
||||
api_app_id: 'A123456',
|
||||
event_id: 'E123456',
|
||||
event_time: 1234567890,
|
||||
event: {
|
||||
type: 'app_mention' as const,
|
||||
user: 'U123456',
|
||||
channel: 'C123456',
|
||||
text: 'Hello Buster',
|
||||
ts: '1234567890.123456',
|
||||
event_ts: '1234567890.123456',
|
||||
},
|
||||
};
|
||||
|
||||
await expect(eventsHandler(payload)).rejects.toThrow(
|
||||
'Unauthorized: Slack user authentication failed'
|
||||
);
|
||||
|
||||
expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456');
|
||||
expect(getSecretByName).toHaveBeenCalledWith('vault-key-123');
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('xoxb-test-token', 'C123456', {
|
||||
text: 'Sorry, you are unauthorized to chat with Buster. Please contact your Workspace Administrator for access.',
|
||||
thread_ts: '1234567890.123456',
|
||||
});
|
||||
});
|
||||
|
||||
it('should send unauthorized message in thread for DM unauthorized users', async () => {
|
||||
const { isEventCallback, isAppMentionEvent, isMessageImEvent, SlackMessagingService } =
|
||||
await import('@buster/slack');
|
||||
const { authenticateSlackUser } = await import('./services/slack-authentication');
|
||||
const { getSecretByName } = await import('@buster/database/queries');
|
||||
|
||||
// Mock event callback detection - this time it's a DM
|
||||
vi.mocked(isEventCallback).mockReturnValue(true);
|
||||
vi.mocked(isAppMentionEvent).mockReturnValue(false);
|
||||
vi.mocked(isMessageImEvent).mockReturnValue(true);
|
||||
|
||||
// Mock authentication to return unauthorized
|
||||
vi.mocked(authenticateSlackUser).mockResolvedValue({
|
||||
type: 'unauthorized',
|
||||
reason: 'User not found in organization',
|
||||
} as any);
|
||||
|
||||
// Mock database query for getting access token
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([{ tokenVaultKey: 'vault-key-123' }]),
|
||||
} as any);
|
||||
|
||||
// Mock vault secret
|
||||
vi.mocked(getSecretByName).mockResolvedValue({
|
||||
secret: 'xoxb-test-token',
|
||||
} as any);
|
||||
|
||||
// Mock messaging service
|
||||
const mockSendMessage = vi.fn().mockResolvedValue({ ok: true, ts: '1234567890.123456' });
|
||||
vi.mocked(SlackMessagingService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
sendMessage: mockSendMessage,
|
||||
}) as any
|
||||
);
|
||||
|
||||
const payload = {
|
||||
type: 'event_callback' as const,
|
||||
token: 'xoxb-test-token',
|
||||
team_id: 'T123456',
|
||||
api_app_id: 'A123456',
|
||||
event_id: 'E123456',
|
||||
event_time: 1234567890,
|
||||
event: {
|
||||
type: 'message' as const,
|
||||
channel_type: 'im' as const,
|
||||
user: 'U123456',
|
||||
channel: 'D123456',
|
||||
text: 'Hello Buster',
|
||||
ts: '1234567890.123456',
|
||||
event_ts: '1234567890.123456',
|
||||
thread_ts: '1234567890.111111', // This is a threaded message
|
||||
},
|
||||
};
|
||||
|
||||
await expect(eventsHandler(payload)).rejects.toThrow(
|
||||
'Unauthorized: Slack user authentication failed'
|
||||
);
|
||||
|
||||
expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456');
|
||||
expect(getSecretByName).toHaveBeenCalledWith('vault-key-123');
|
||||
expect(mockSendMessage).toHaveBeenCalledWith('xoxb-test-token', 'D123456', {
|
||||
text: 'Sorry, you are unauthorized to chat with Buster. Please contact your Workspace Administrator for access.',
|
||||
thread_ts: '1234567890.111111', // Should use the existing thread_ts
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle failure to send unauthorized message gracefully', async () => {
|
||||
const { isEventCallback, isAppMentionEvent, isMessageImEvent, SlackMessagingService } =
|
||||
await import('@buster/slack');
|
||||
const { authenticateSlackUser } = await import('./services/slack-authentication');
|
||||
const { getSecretByName } = await import('@buster/database/queries');
|
||||
|
||||
// Mock event callback detection
|
||||
vi.mocked(isEventCallback).mockReturnValue(true);
|
||||
vi.mocked(isAppMentionEvent).mockReturnValue(true);
|
||||
vi.mocked(isMessageImEvent).mockReturnValue(false);
|
||||
|
||||
// Mock authentication to return unauthorized
|
||||
vi.mocked(authenticateSlackUser).mockResolvedValue({
|
||||
type: 'unauthorized',
|
||||
reason: 'User not authorized',
|
||||
} as any);
|
||||
|
||||
// Mock database query for getting access token
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([{ tokenVaultKey: 'vault-key-123' }]),
|
||||
} as any);
|
||||
|
||||
// Mock vault secret
|
||||
vi.mocked(getSecretByName).mockResolvedValue({
|
||||
secret: 'xoxb-test-token',
|
||||
} as any);
|
||||
|
||||
// Mock messaging service to throw error
|
||||
const mockSendMessage = vi.fn().mockRejectedValue(new Error('Slack API error'));
|
||||
vi.mocked(SlackMessagingService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
sendMessage: mockSendMessage,
|
||||
}) as any
|
||||
);
|
||||
|
||||
const payload = {
|
||||
type: 'event_callback' as const,
|
||||
token: 'xoxb-test-token',
|
||||
team_id: 'T123456',
|
||||
api_app_id: 'A123456',
|
||||
event_id: 'E123456',
|
||||
event_time: 1234567890,
|
||||
event: {
|
||||
type: 'app_mention' as const,
|
||||
user: 'U123456',
|
||||
channel: 'C123456',
|
||||
text: 'Hello Buster',
|
||||
ts: '1234567890.123456',
|
||||
event_ts: '1234567890.123456',
|
||||
},
|
||||
};
|
||||
|
||||
// Should still throw the unauthorized error even if message sending fails
|
||||
await expect(eventsHandler(payload)).rejects.toThrow(
|
||||
'Unauthorized: Slack user authentication failed'
|
||||
);
|
||||
|
||||
expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456');
|
||||
expect(mockSendMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle case when no access token is available for unauthorized message', async () => {
|
||||
const { isEventCallback, isAppMentionEvent, isMessageImEvent, SlackMessagingService } =
|
||||
await import('@buster/slack');
|
||||
const { authenticateSlackUser } = await import('./services/slack-authentication');
|
||||
const { getSecretByName } = await import('@buster/database/queries');
|
||||
|
||||
// Mock event callback detection
|
||||
vi.mocked(isEventCallback).mockReturnValue(true);
|
||||
vi.mocked(isAppMentionEvent).mockReturnValue(true);
|
||||
vi.mocked(isMessageImEvent).mockReturnValue(false);
|
||||
|
||||
// Mock authentication to return unauthorized
|
||||
vi.mocked(authenticateSlackUser).mockResolvedValue({
|
||||
type: 'unauthorized',
|
||||
reason: 'User not authorized',
|
||||
} as any);
|
||||
|
||||
// Mock database query to return empty result (no token found)
|
||||
vi.mocked(db.select).mockReturnValue({
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockResolvedValue([]),
|
||||
} as any);
|
||||
|
||||
// Mock messaging service (shouldn't be called)
|
||||
const mockSendMessage = vi.fn();
|
||||
vi.mocked(SlackMessagingService).mockImplementation(
|
||||
() =>
|
||||
({
|
||||
sendMessage: mockSendMessage,
|
||||
}) as any
|
||||
);
|
||||
|
||||
const payload = {
|
||||
type: 'event_callback' as const,
|
||||
token: 'xoxb-test-token',
|
||||
team_id: 'T123456',
|
||||
api_app_id: 'A123456',
|
||||
event_id: 'E123456',
|
||||
event_time: 1234567890,
|
||||
event: {
|
||||
type: 'app_mention' as const,
|
||||
user: 'U123456',
|
||||
channel: 'C123456',
|
||||
text: 'Hello Buster',
|
||||
ts: '1234567890.123456',
|
||||
event_ts: '1234567890.123456',
|
||||
},
|
||||
};
|
||||
|
||||
// Should still throw the unauthorized error even when no token is available
|
||||
await expect(eventsHandler(payload)).rejects.toThrow(
|
||||
'Unauthorized: Slack user authentication failed'
|
||||
);
|
||||
|
||||
expect(authenticateSlackUser).toHaveBeenCalledWith('U123456', 'T123456');
|
||||
expect(getSecretByName).not.toHaveBeenCalled();
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -3,6 +3,7 @@ import { getSecretByName } from '@buster/database/queries';
|
|||
import { chats, slackIntegrations } from '@buster/database/schema';
|
||||
import type { SlackEventsResponse } from '@buster/server-shared/slack';
|
||||
import {
|
||||
SlackMessagingService,
|
||||
type SlackWebhookPayload,
|
||||
addReaction,
|
||||
isAppMentionEvent,
|
||||
|
@ -18,6 +19,41 @@ import {
|
|||
getUserIdFromAuthResult,
|
||||
} from './services/slack-authentication';
|
||||
|
||||
/**
|
||||
* Helper function to get Slack access token from vault
|
||||
*/
|
||||
async function getSlackAccessToken(
|
||||
teamId: string,
|
||||
organizationId?: string
|
||||
): Promise<string | null> {
|
||||
const filters = [eq(slackIntegrations.teamId, teamId), eq(slackIntegrations.status, 'active')];
|
||||
if (organizationId) {
|
||||
filters.push(eq(slackIntegrations.organizationId, organizationId));
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch Slack integration to get token vault key
|
||||
const slackIntegration = await db
|
||||
.select({
|
||||
tokenVaultKey: slackIntegrations.tokenVaultKey,
|
||||
})
|
||||
.from(slackIntegrations)
|
||||
.where(and(...filters))
|
||||
.limit(1);
|
||||
|
||||
if (slackIntegration.length > 0 && slackIntegration[0]?.tokenVaultKey) {
|
||||
// Get the access token from vault
|
||||
const vaultSecret = await getSecretByName(slackIntegration[0].tokenVaultKey);
|
||||
return vaultSecret?.secret || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Failed to get Slack access token from vault:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map authentication result type to database enum value
|
||||
*/
|
||||
|
@ -176,9 +212,9 @@ export async function handleSlackEventsEndpoint(c: Context) {
|
|||
|
||||
return c.json(response);
|
||||
} catch (error) {
|
||||
// Handle authentication errors
|
||||
// Handle authentication errors with 200 status code to prevent Slack from retrying the webhook
|
||||
if (error instanceof Error && error.message.includes('Unauthorized')) {
|
||||
return c.json({ error: 'Unauthorized' }, 401);
|
||||
return c.json({ error: 'Unauthorized' }, 200);
|
||||
}
|
||||
// Re-throw other errors
|
||||
throw error;
|
||||
|
@ -212,19 +248,41 @@ export async function eventsHandler(payload: SlackWebhookPayload): Promise<Slack
|
|||
// Authenticate the Slack user
|
||||
const authResult = await authenticateSlackUser(event.user, payload.team_id);
|
||||
|
||||
// Check if authentication was successful
|
||||
const userId = getUserIdFromAuthResult(authResult);
|
||||
if (!userId) {
|
||||
console.warn('Slack user authentication failed:', {
|
||||
slackUserId: event.user,
|
||||
teamId: payload.team_id,
|
||||
reason: authResult.type === 'unauthorized' ? authResult.reason : 'Unknown',
|
||||
if (authResult.type === 'unauthorized') {
|
||||
if (authResult.reason.toLowerCase().includes('bot')) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const accessToken = await getSlackAccessToken(payload.team_id);
|
||||
if (accessToken) {
|
||||
const messagingService = new SlackMessagingService();
|
||||
const threadTs = event.thread_ts || event.ts;
|
||||
|
||||
await messagingService.sendMessage(accessToken, event.channel, {
|
||||
text: 'Sorry, you are unauthorized to chat with Buster. Please contact your Workspace Administrator for access.',
|
||||
thread_ts: threadTs,
|
||||
});
|
||||
// Throw unauthorized error
|
||||
|
||||
console.info('Sent unauthorized message to Slack user', {
|
||||
channel: event.channel,
|
||||
user: event.user,
|
||||
threadTs,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to send unauthorized message to Slack user', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
channel: event.channel,
|
||||
user: event.user,
|
||||
threadTs: event.thread_ts || event.ts,
|
||||
});
|
||||
}
|
||||
throw new Error('Unauthorized: Slack user authentication failed');
|
||||
}
|
||||
|
||||
const organizationId = authResult.type === 'unauthorized' ? '' : authResult.organization.id;
|
||||
const userId = authResult.user.id;
|
||||
const organizationId = authResult.organization.id;
|
||||
|
||||
// Extract thread timestamp - if no thread_ts, this is a new thread so use ts
|
||||
const threadTs = event.thread_ts || event.ts;
|
||||
|
@ -232,29 +290,12 @@ export async function eventsHandler(payload: SlackWebhookPayload): Promise<Slack
|
|||
// Add hourglass reaction immediately after authentication
|
||||
if (organizationId) {
|
||||
try {
|
||||
// Fetch Slack integration to get token vault key
|
||||
const slackIntegration = await db
|
||||
.select({
|
||||
tokenVaultKey: slackIntegrations.tokenVaultKey,
|
||||
})
|
||||
.from(slackIntegrations)
|
||||
.where(
|
||||
and(
|
||||
eq(slackIntegrations.organizationId, organizationId),
|
||||
eq(slackIntegrations.teamId, payload.team_id),
|
||||
eq(slackIntegrations.status, 'active')
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const accessToken = await getSlackAccessToken(payload.team_id, organizationId);
|
||||
|
||||
if (slackIntegration.length > 0 && slackIntegration[0]?.tokenVaultKey) {
|
||||
// Get the access token from vault
|
||||
const vaultSecret = await getSecretByName(slackIntegration[0].tokenVaultKey);
|
||||
|
||||
if (vaultSecret?.secret) {
|
||||
if (accessToken) {
|
||||
// Add the hourglass reaction
|
||||
await addReaction({
|
||||
accessToken: vaultSecret.secret,
|
||||
accessToken,
|
||||
channelId: event.channel,
|
||||
messageTs: event.ts,
|
||||
emoji: 'hourglass_flowing_sand',
|
||||
|
@ -265,7 +306,6 @@ export async function eventsHandler(payload: SlackWebhookPayload): Promise<Slack
|
|||
messageTs: event.ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log but don't fail the entire process if reaction fails
|
||||
console.warn('Failed to add hourglass reaction', {
|
||||
|
|
|
@ -48,12 +48,10 @@ export async function checkPermission(check: AssetPermissionCheck): Promise<Asse
|
|||
} = check;
|
||||
|
||||
// Check cache first (only for single role checks)
|
||||
if (!Array.isArray(requiredRole)) {
|
||||
const cached = getCachedPermission(userId, assetId, assetType, requiredRole);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user's organization memberships
|
||||
const userOrgs = await getUserOrganizationsByUserId(userId);
|
||||
|
@ -112,6 +110,8 @@ export async function checkPermission(check: AssetPermissionCheck): Promise<Asse
|
|||
}
|
||||
}
|
||||
|
||||
console.info('publiclyAccessible', publiclyAccessible);
|
||||
|
||||
if (publiclyAccessible) {
|
||||
const hasPublicAccessCheck = hasPublicAccess(
|
||||
publiclyAccessible,
|
||||
|
|
|
@ -344,12 +344,12 @@ You operate in a loop to complete tasks:
|
|||
- Strict JOINs: Only join tables where relationships are explicitly defined via `relationships` or `entities` keys in the provided data context/metadata. Do not join tables without a pre-defined relationship.
|
||||
- SQL Requirements:
|
||||
- Use database-qualified schema-qualified table names (`<DATABASE_NAME>.<SCHEMA_NAME>.<TABLE_NAME>`).
|
||||
- Use fully qualified column names with table aliases (e.g., `<table_alias>.<column>`).
|
||||
- Use column names qualified with table aliases (e.g., `<table_alias>.<column>`).
|
||||
- MANDATORY SQL NAMING CONVENTIONS:
|
||||
- All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`.
|
||||
- All Column References: MUST be qualified with their table alias (e.g., `alias.column_name`) or CTE name (e.g., `cte_alias.column_name_from_cte`).
|
||||
- Inside CTE Definitions: When defining a CTE (e.g., `WITH my_cte AS (SELECT t.column1 FROM DATABASE.SCHEMA.TABLE1 t ...)`), all columns selected from underlying database tables MUST use their table alias (e.g., `t.column1`, not just `column1`). This applies even if the CTE is simple and selects from only one table.
|
||||
- Selecting From CTEs: When selecting from a defined CTE, use the CTE's alias for its columns (e.g., `SELECT mc.column1 FROM my_cte mc ...`).
|
||||
- All Column References: MUST be qualified with their table alias (e.g., `c.customerid`) or CTE name (e.g., `cte_alias.column_name_from_cte`).
|
||||
- Inside CTE Definitions: When defining a CTE (e.g., `WITH my_cte AS (SELECT c.customerid FROM DATABASE.SCHEMA.TABLE1 c ...)`), all columns selected from underlying database tables MUST use their table alias (e.g., `c.customerid`, not just `customerid`). This applies even if the CTE is simple and selects from only one table.
|
||||
- Selecting From CTEs: When selecting from a defined CTE, use the CTE's alias for its columns (e.g., `SELECT mc.column_name FROM my_cte mc ...`).
|
||||
- Universal Application: These naming conventions are strict requirements and apply universally to all parts of the SQL query, including every CTE definition and every subsequent SELECT statement. Non-compliance will lead to errors.
|
||||
- Context Adherence: Strictly use only columns that are present in the data context provided by search results. Never invent or assume columns.
|
||||
- Select specific columns (avoid `SELECT *` or `COUNT(*)`).
|
||||
|
|
|
@ -75,4 +75,35 @@ describe('Analyst Agent Instructions', () => {
|
|||
getAnalystAgentSystemPrompt(' '); // whitespace only
|
||||
}).toThrow('SQL dialect guidance is required');
|
||||
});
|
||||
|
||||
it('should contain mandatory SQL naming conventions', () => {
|
||||
const result = getAnalystAgentSystemPrompt('Test guidance');
|
||||
|
||||
// Check for MANDATORY SQL NAMING CONVENTIONS section
|
||||
expect(result).toContain('MANDATORY SQL NAMING CONVENTIONS');
|
||||
|
||||
// Ensure table references require full qualification
|
||||
expect(result).toContain('All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`');
|
||||
|
||||
// Ensure column references use table aliases (not full qualifiers)
|
||||
expect(result).toContain('All Column References: MUST be qualified with their table alias (e.g., `c.customerid`)');
|
||||
|
||||
// Ensure examples show table alias usage without full qualification
|
||||
expect(result).toContain('c.customerid');
|
||||
expect(result).not.toContain('postgres.ont_ont.customer.customerid');
|
||||
|
||||
// Ensure CTE examples use table aliases correctly
|
||||
expect(result).toContain('SELECT c.customerid FROM DATABASE.SCHEMA.TABLE1 c');
|
||||
expect(result).toContain('c.customerid`, not just `customerid`');
|
||||
});
|
||||
|
||||
it('should use column names qualified with table aliases', () => {
|
||||
const result = getAnalystAgentSystemPrompt('Test guidance');
|
||||
|
||||
// Check for the updated description
|
||||
expect(result).toContain('Use column names qualified with table aliases');
|
||||
|
||||
// Ensure the old verbose description is not present
|
||||
expect(result).not.toContain('Use fully qualified column names with table aliases');
|
||||
});
|
||||
});
|
||||
|
|
|
@ -145,4 +145,40 @@ describe('Think and Prep Agent Instructions', () => {
|
|||
getThinkAndPrepAgentSystemPrompt(' '); // whitespace only
|
||||
}).toThrow('SQL dialect guidance is required');
|
||||
});
|
||||
|
||||
describe.each([
|
||||
['standard', 'standard'],
|
||||
['investigation', 'investigation'],
|
||||
])('SQL naming conventions in %s mode', (modeName, mode) => {
|
||||
it(`should contain mandatory SQL naming conventions in ${modeName} mode`, () => {
|
||||
const result = getThinkAndPrepAgentSystemPrompt('Test guidance', mode as 'standard' | 'investigation');
|
||||
|
||||
// Check for MANDATORY SQL NAMING CONVENTIONS section
|
||||
expect(result).toContain('MANDATORY SQL NAMING CONVENTIONS');
|
||||
|
||||
// Ensure table references require full qualification
|
||||
expect(result).toContain('All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`');
|
||||
|
||||
// Ensure column references use table aliases (not full qualifiers)
|
||||
expect(result).toContain('All Column References: MUST be qualified with their table alias (e.g., `c.customerid`)');
|
||||
|
||||
// Ensure examples show table alias usage without full qualification
|
||||
expect(result).toContain('c.customerid');
|
||||
expect(result).not.toContain('postgres.ont_ont.customer.customerid');
|
||||
|
||||
// Ensure CTE examples use table aliases correctly
|
||||
expect(result).toContain('SELECT c.customerid FROM DATABASE.SCHEMA.TABLE1 c');
|
||||
expect(result).toContain('c.customerid`, not just `customerid`');
|
||||
});
|
||||
|
||||
it(`should use column names qualified with table aliases in ${modeName} mode`, () => {
|
||||
const result = getThinkAndPrepAgentSystemPrompt('Test guidance', mode as 'standard' | 'investigation');
|
||||
|
||||
// Check for the updated description
|
||||
expect(result).toContain('Use column names qualified with table aliases');
|
||||
|
||||
// Ensure the old verbose description is not present
|
||||
expect(result).not.toContain('Use fully qualified column names with table aliases');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -588,12 +588,12 @@ If all true → proceed to submit prep for Asset Creation with `submitThoughts`.
|
|||
- Strict JOINs: Only join tables where relationships are explicitly defined via `relationships` or `entities` keys in the provided data context/metadata. Do not join tables without a pre-defined relationship.
|
||||
- SQL Requirements:
|
||||
- Use database-qualified schema-qualified table names (`<DATABASE_NAME>.<SCHEMA_NAME>.<TABLE_NAME>`).
|
||||
- Use fully qualified column names with table aliases (e.g., `<table_alias>.<column>`).
|
||||
- Use column names qualified with table aliases (e.g., `<table_alias>.<column>`).
|
||||
- MANDATORY SQL NAMING CONVENTIONS:
|
||||
- All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`.
|
||||
- All Column References: MUST be qualified with their table alias (e.g., `alias.column_name`) or CTE name (e.g., `cte_alias.column_name_from_cte`).
|
||||
- Inside CTE Definitions: When defining a CTE (e.g., `WITH my_cte AS (SELECT t.column1 FROM DATABASE.SCHEMA.TABLE1 t ...)`), all columns selected from underlying database tables MUST use their table alias (e.g., `t.column1`, not just `column1`). This applies even if the CTE is simple and selects from only one table.
|
||||
- Selecting From CTEs: When selecting from a defined CTE, use the CTE's alias for its columns (e.g., `SELECT mc.column1 FROM my_cte mc ...`).
|
||||
- All Column References: MUST be qualified with their table alias (e.g., `c.customerid`) or CTE name (e.g., `cte_alias.column_name_from_cte`).
|
||||
- Inside CTE Definitions: When defining a CTE (e.g., `WITH my_cte AS (SELECT c.customerid FROM DATABASE.SCHEMA.TABLE1 c ...)`), all columns selected from underlying database tables MUST use their table alias (e.g., `c.customerid`, not just `customerid`). This applies even if the CTE is simple and selects from only one table.
|
||||
- Selecting From CTEs: When selecting from a defined CTE, use the CTE's alias for its columns (e.g., `SELECT mc.column_name FROM my_cte mc ...`).
|
||||
- Universal Application: These naming conventions are strict requirements and apply universally to all parts of the SQL query, including every CTE definition and every subsequent SELECT statement. Non-compliance will lead to errors.
|
||||
- Context Adherence: Strictly use only columns that are present in the data context provided by search results. Never invent or assume columns.
|
||||
- Select specific columns (avoid `SELECT *` or `COUNT(*)`).
|
||||
|
|
|
@ -465,12 +465,12 @@ When in doubt, be more thorough rather than less. Reports are the default becaus
|
|||
- Strict JOINs: Only join tables where relationships are explicitly defined via `relationships` or `entities` keys in the provided data context/metadata. Do not join tables without a pre-defined relationship.
|
||||
- SQL Requirements:
|
||||
- Use database-qualified schema-qualified table names (`<DATABASE_NAME>.<SCHEMA_NAME>.<TABLE_NAME>`).
|
||||
- Use fully qualified column names with table aliases (e.g., `<table_alias>.<column>`).
|
||||
- Use column names qualified with table aliases (e.g., `<table_alias>.<column>`).
|
||||
- MANDATORY SQL NAMING CONVENTIONS:
|
||||
- All Table References: MUST be fully qualified: `DATABASE_NAME.SCHEMA_NAME.TABLE_NAME`.
|
||||
- All Column References: MUST be qualified with their table alias (e.g., `alias.column_name`) or CTE name (e.g., `cte_alias.column_name_from_cte`).
|
||||
- Inside CTE Definitions: When defining a CTE (e.g., `WITH my_cte AS (SELECT t.column1 FROM DATABASE.SCHEMA.TABLE1 t ...)`), all columns selected from underlying database tables MUST use their table alias (e.g., `t.column1`, not just `column1`). This applies even if the CTE is simple and selects from only one table.
|
||||
- Selecting From CTEs: When selecting from a defined CTE, use the CTE's alias for its columns (e.g., `SELECT mc.column1 FROM my_cte mc ...`).
|
||||
- All Column References: MUST be qualified with their table alias (e.g., `c.customerid`) or CTE name (e.g., `cte_alias.column_name_from_cte`).
|
||||
- Inside CTE Definitions: When defining a CTE (e.g., `WITH my_cte AS (SELECT c.customerid FROM DATABASE.SCHEMA.TABLE1 c ...)`), all columns selected from underlying database tables MUST use their table alias (e.g., `c.customerid`, not just `customerid`). This applies even if the CTE is simple and selects from only one table.
|
||||
- Selecting From CTEs: When selecting from a defined CTE, use the CTE's alias for its columns (e.g., `SELECT mc.column_name FROM my_cte mc ...`).
|
||||
- Universal Application: These naming conventions are strict requirements and apply universally to all parts of the SQL query, including every CTE definition and every subsequent SELECT statement. Non-compliance will lead to errors.
|
||||
- Context Adherence: Strictly use only columns that are present in the data context provided by search results. Never invent or assume columns.
|
||||
- Select specific columns (avoid `SELECT *` or `COUNT(*)`).
|
||||
|
|
|
@ -6,8 +6,15 @@ export const DEFAULT_ANTHROPIC_OPTIONS = {
|
|||
gateway: {
|
||||
order: ['bedrock', 'anthropic', 'vertex'],
|
||||
},
|
||||
headers: {},
|
||||
anthropic: { cacheControl: { type: 'ephemeral' } },
|
||||
anthropic: {
|
||||
cacheControl: { type: 'ephemeral' },
|
||||
},
|
||||
bedrock: {
|
||||
cacheControl: { type: 'ephemeral' },
|
||||
additionalModelRequestFields: {
|
||||
anthropic_beta: ['fine-grained-tool-streaming-2025-05-14'],
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const DEFAULT_OPENAI_OPTIONS = {
|
||||
|
@ -15,7 +22,7 @@ export const DEFAULT_OPENAI_OPTIONS = {
|
|||
order: ['openai'],
|
||||
},
|
||||
openai: {
|
||||
parallelToolCalls: false,
|
||||
// parallelToolCalls: false,
|
||||
reasoningEffort: 'minimal',
|
||||
verbosity: 'low',
|
||||
},
|
||||
|
|
|
@ -3,8 +3,8 @@ import { generateObject } from 'ai';
|
|||
import type { ModelMessage } from 'ai';
|
||||
import { wrapTraced } from 'braintrust';
|
||||
import { z } from 'zod';
|
||||
import { GPT5Nano } from '../../../llm';
|
||||
import { DEFAULT_OPENAI_OPTIONS } from '../../../llm/providers/gateway';
|
||||
import { Haiku35 } from '../../../llm';
|
||||
import { DEFAULT_ANTHROPIC_OPTIONS } from '../../../llm/providers/gateway';
|
||||
|
||||
// Zod-first: define input/output schemas and export inferred types
|
||||
export const generateChatTitleParamsSchema = z.object({
|
||||
|
@ -56,10 +56,10 @@ async function generateTitleWithLLM(messages: ModelMessage[]): Promise<string> {
|
|||
const tracedChatTitle = wrapTraced(
|
||||
async () => {
|
||||
const { object } = await generateObject({
|
||||
model: GPT5Nano,
|
||||
model: Haiku35,
|
||||
schema: llmOutputSchema,
|
||||
messages: titleMessages,
|
||||
providerOptions: DEFAULT_OPENAI_OPTIONS,
|
||||
providerOptions: DEFAULT_ANTHROPIC_OPTIONS,
|
||||
});
|
||||
|
||||
return object;
|
||||
|
|
|
@ -21,16 +21,14 @@ const UpdateMessageEntriesSchema = z.object({
|
|||
|
||||
export type UpdateMessageEntriesParams = z.infer<typeof UpdateMessageEntriesSchema>;
|
||||
|
||||
// Simple in-memory queue for each messageId
|
||||
const updateQueues = new Map<string, Promise<{ success: boolean }>>();
|
||||
|
||||
/**
|
||||
* Updates message entries with cache-first approach for streaming.
|
||||
* Cache is the source of truth during streaming, DB is updated for persistence.
|
||||
*
|
||||
* Merge logic:
|
||||
* - responseMessages: upsert by 'id' field, maintaining order
|
||||
* - reasoningMessages: upsert by 'id' field, maintaining order
|
||||
* - rawLlmMessages: upsert by combination of 'role' and 'toolCallId', maintaining order
|
||||
* Internal function that performs the actual update logic.
|
||||
* This is separated so it can be queued.
|
||||
*/
|
||||
export async function updateMessageEntries({
|
||||
async function performUpdate({
|
||||
messageId,
|
||||
rawLlmMessages,
|
||||
responseMessages,
|
||||
|
@ -95,3 +93,41 @@ export async function updateMessageEntries({
|
|||
throw new Error(`Failed to update message entries for message ${messageId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates message entries with cache-first approach for streaming.
|
||||
* Cache is the source of truth during streaming, DB is updated for persistence.
|
||||
*
|
||||
* Updates are queued per messageId to ensure they execute in order.
|
||||
*
|
||||
* Merge logic:
|
||||
* - responseMessages: upsert by 'id' field, maintaining order
|
||||
* - reasoningMessages: upsert by 'id' field, maintaining order
|
||||
* - rawLlmMessages: upsert by combination of 'role' and 'toolCallId', maintaining order
|
||||
*/
|
||||
export async function updateMessageEntries(
|
||||
params: UpdateMessageEntriesParams
|
||||
): Promise<{ success: boolean }> {
|
||||
const { messageId } = params;
|
||||
|
||||
// Get the current promise for this messageId, or use a resolved promise as the starting point
|
||||
const currentQueue = updateQueues.get(messageId) ?? Promise.resolve({ success: true });
|
||||
|
||||
// Chain the new update to run after the current queue completes
|
||||
const newQueue = currentQueue
|
||||
.then(() => performUpdate(params))
|
||||
.catch(() => performUpdate(params)); // Still try to run even if previous failed
|
||||
|
||||
// Update the queue for this messageId
|
||||
updateQueues.set(messageId, newQueue);
|
||||
|
||||
// Clean up the queue entry once this update completes
|
||||
newQueue.finally(() => {
|
||||
// Only remove if this is still the current queue
|
||||
if (updateQueues.get(messageId) === newQueue) {
|
||||
updateQueues.delete(messageId);
|
||||
}
|
||||
});
|
||||
|
||||
return newQueue;
|
||||
}
|
||||
|
|
|
@ -31,18 +31,26 @@ type VersionHistoryEntry = {
|
|||
|
||||
type VersionHistory = Record<string, VersionHistoryEntry>;
|
||||
|
||||
// Simple in-memory queue for each reportId
|
||||
const updateQueues = new Map<string, Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
versionHistory: VersionHistory | null;
|
||||
}>>();
|
||||
|
||||
/**
|
||||
* Updates a report with new content, optionally name, and version history in a single operation
|
||||
* This is more efficient than multiple individual updates
|
||||
* Internal function that performs the actual update logic.
|
||||
* This is separated so it can be queued.
|
||||
*/
|
||||
export const batchUpdateReport = async (
|
||||
async function performUpdate(
|
||||
params: BatchUpdateReportInput
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
versionHistory: VersionHistory | null;
|
||||
}> => {
|
||||
}> {
|
||||
const { reportId, content, name, versionHistory } = BatchUpdateReportInputSchema.parse(params);
|
||||
|
||||
try {
|
||||
|
@ -93,4 +101,47 @@ export const batchUpdateReport = async (
|
|||
|
||||
throw new Error('Failed to batch update report');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a report with new content, optionally name, and version history in a single operation
|
||||
* This is more efficient than multiple individual updates
|
||||
*
|
||||
* Updates are queued per reportId to ensure they execute in order.
|
||||
*/
|
||||
export const batchUpdateReport = async (
|
||||
params: BatchUpdateReportInput
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
versionHistory: VersionHistory | null;
|
||||
}> => {
|
||||
const { reportId } = params;
|
||||
|
||||
// Get the current promise for this reportId, or use a resolved promise as the starting point
|
||||
const currentQueue = updateQueues.get(reportId) ?? Promise.resolve({
|
||||
id: '',
|
||||
name: '',
|
||||
content: '',
|
||||
versionHistory: null
|
||||
});
|
||||
|
||||
// Chain the new update to run after the current queue completes
|
||||
const newQueue = currentQueue
|
||||
.then(() => performUpdate(params))
|
||||
.catch(() => performUpdate(params)); // Still try to run even if previous failed
|
||||
|
||||
// Update the queue for this reportId
|
||||
updateQueues.set(reportId, newQueue);
|
||||
|
||||
// Clean up the queue entry once this update completes
|
||||
newQueue.finally(() => {
|
||||
// Only remove if this is still the current queue
|
||||
if (updateQueues.get(reportId) === newQueue) {
|
||||
updateQueues.delete(reportId);
|
||||
}
|
||||
});
|
||||
|
||||
return newQueue;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue