Merge pull request #603 from buster-so/devin/BUS-1494-1753276227

Implement /api/v2/title endpoint for asset title retrieval (BUS-1494)
This commit is contained in:
Nate Kelley 2025-07-24 17:43:41 -06:00 committed by GitHub
commit 633a77e2ef
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 269 additions and 7 deletions

View File

@ -8,6 +8,7 @@ import organizationRoutes from './organization';
import securityRoutes from './security';
import slackRoutes from './slack';
import supportRoutes from './support';
import titleRoutes from './title';
import userRoutes from './users';
const app = new Hono()
@ -19,6 +20,7 @@ const app = new Hono()
.route('/support', supportRoutes)
.route('/security', securityRoutes)
.route('/organizations', organizationRoutes)
.route('/dictionaries', dictionariesRoutes);
.route('/dictionaries', dictionariesRoutes)
.route('/title', titleRoutes);
export default app;

View File

@ -0,0 +1,57 @@
import {
getChatTitle,
getCollectionTitle,
getDashboardTitle,
getMetricTitle,
getUserOrganizationId,
} from '@buster/database';
import { GetTitleRequestSchema, type GetTitleResponse } from '@buster/server-shared/title';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { requireAuth } from '../../../middleware/auth';
import { standardErrorHandler } from '../../../utils/response';
const app = new Hono()
.use('*', requireAuth)
.get('/', zValidator('query', GetTitleRequestSchema), async (c) => {
try {
const { assetId, assetType } = c.req.valid('query');
const user = c.get('busterUser');
const userOrg = await getUserOrganizationId(user.id);
let title: string | null = null;
switch (assetType) {
case 'chat':
title = await getChatTitle({ assetId, organizationId: userOrg?.organizationId });
break;
case 'metric':
title = await getMetricTitle({ assetId, organizationId: userOrg?.organizationId });
break;
case 'collection':
title = await getCollectionTitle({ assetId, organizationId: userOrg?.organizationId });
break;
case 'dashboard':
title = await getDashboardTitle({ assetId, organizationId: userOrg?.organizationId });
break;
default: {
const _exhaustive: never = assetType;
throw new HTTPException(400, { message: `Unsupported asset type: ${assetType}` });
}
}
if (title === null) {
throw new HTTPException(404, { message: 'Asset not found or access denied' });
}
const response: GetTitleResponse = { title };
return c.json(response);
} catch (error) {
return standardErrorHandler(error, c);
}
})
.onError(standardErrorHandler);
export default app;

View File

@ -0,0 +1,3 @@
import app from './GET';
export default app;

View File

@ -6,10 +6,4 @@ export {
type GenerateAssetMessagesInput,
} from './assets';
export {
getChatDashboardFiles,
type DashboardFileContext,
type DashboardFile,
} from './dashboards';
export type { DatabaseAssetType } from './assets';

View File

@ -0,0 +1,35 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { chats } from '../../schema';
export const GetChatTitleInputSchema = z.object({
assetId: z.string().uuid(),
organizationId: z.string().uuid().optional(),
});
export type GetChatTitleInput = z.infer<typeof GetChatTitleInputSchema>;
export async function getChatTitle(input: GetChatTitleInput): Promise<string | null> {
const validated = GetChatTitleInputSchema.parse(input);
const [chat] = await db
.select({
title: chats.title,
publiclyAccessible: chats.publiclyAccessible,
organizationId: chats.organizationId,
})
.from(chats)
.where(and(eq(chats.id, validated.assetId), isNull(chats.deletedAt)))
.limit(1);
if (!chat) {
return null;
}
if (!chat.publiclyAccessible && chat.organizationId !== validated.organizationId) {
return null;
}
return chat.title;
}

View File

@ -12,3 +12,9 @@ export {
type CreateMessageInput,
type Chat,
} from './chats';
export {
getChatTitle,
GetChatTitleInputSchema,
type GetChatTitleInput,
} from './get-chat-title';

View File

@ -0,0 +1,39 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { collections } from '../../schema';
export const GetCollectionTitleInputSchema = z.object({
assetId: z.string().uuid(),
organizationId: z.string().uuid().optional(),
});
export type GetCollectionTitleInput = z.infer<typeof GetCollectionTitleInputSchema>;
// Updated return type to remove null since we now throw an error instead
export async function getCollectionTitle(input: GetCollectionTitleInput): Promise<string> {
const validated = GetCollectionTitleInputSchema.parse(input);
const [collection] = await db
.select({
name: collections.name,
organizationId: collections.organizationId,
})
.from(collections)
.where(and(eq(collections.id, validated.assetId), isNull(collections.deletedAt)))
.limit(1);
// Throw error instead of returning null
if (!collection) {
throw new Error(`Collection with ID ${validated.assetId} not found`);
}
// Throw error for permission failure instead of returning null
if (collection.organizationId !== validated.organizationId) {
throw new Error(
`Access denied: Collection with ID ${validated.assetId} does not belong to the specified organization`
);
}
return collection.name;
}

View File

@ -0,0 +1,5 @@
export {
getCollectionTitle,
GetCollectionTitleInputSchema,
type GetCollectionTitleInput,
} from './get-collection-title';

View File

@ -0,0 +1,40 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { dashboardFiles } from '../../schema';
export const GetDashboardTitleInputSchema = z.object({
assetId: z.string().uuid(),
organizationId: z.string().uuid().optional(),
});
export type GetDashboardTitleInput = z.infer<typeof GetDashboardTitleInputSchema>;
// Updated return type to remove null since we now throw an error instead
export async function getDashboardTitle(input: GetDashboardTitleInput): Promise<string> {
const validated = GetDashboardTitleInputSchema.parse(input);
const [dashboard] = await db
.select({
name: dashboardFiles.name,
publiclyAccessible: dashboardFiles.publiclyAccessible,
organizationId: dashboardFiles.organizationId,
})
.from(dashboardFiles)
.where(and(eq(dashboardFiles.id, validated.assetId), isNull(dashboardFiles.deletedAt)))
.limit(1);
// Throw error instead of returning null
if (!dashboard) {
throw new Error(`Dashboard with ID ${validated.assetId} not found`);
}
// Throw error for permission failure instead of returning null
if (!dashboard.publiclyAccessible && dashboard.organizationId !== validated.organizationId) {
throw new Error(
`Access denied: Dashboard with ID ${validated.assetId} is not publicly accessible and does not belong to the specified organization`
);
}
return dashboard.name;
}

View File

@ -0,0 +1,11 @@
export {
getChatDashboardFiles,
type DashboardFileContext,
type DashboardFile,
} from './dashboards';
export {
getDashboardTitle,
GetDashboardTitleInputSchema,
type GetDashboardTitleInput,
} from './get-dashboard-title';

View File

@ -5,3 +5,6 @@ export * from './assets';
export * from './metadata';
export * from './chats';
export * from './organizations';
export * from './dashboards';
export * from './metrics';
export * from './collections';

View File

@ -0,0 +1,40 @@
import { and, eq, isNull } from 'drizzle-orm';
import { z } from 'zod';
import { db } from '../../connection';
import { metricFiles } from '../../schema';
export const GetMetricTitleInputSchema = z.object({
assetId: z.string().uuid(),
organizationId: z.string().uuid().optional(),
});
export type GetMetricTitleInput = z.infer<typeof GetMetricTitleInputSchema>;
// Updated return type to remove null since we now throw an error instead
export async function getMetricTitle(input: GetMetricTitleInput): Promise<string> {
const validated = GetMetricTitleInputSchema.parse(input);
const [metric] = await db
.select({
name: metricFiles.name,
publiclyAccessible: metricFiles.publiclyAccessible,
organizationId: metricFiles.organizationId,
})
.from(metricFiles)
.where(and(eq(metricFiles.id, validated.assetId), isNull(metricFiles.deletedAt)))
.limit(1);
// Throw error instead of returning null
if (!metric) {
throw new Error(`Metric with ID ${validated.assetId} not found`);
}
// Throw error for permission failure instead of returning null
if (!metric.publiclyAccessible && metric.organizationId !== validated.organizationId) {
throw new Error(
`Access denied: Metric with ID ${validated.assetId} is not publicly accessible and does not belong to the specified organization`
);
}
return metric.name;
}

View File

@ -0,0 +1,5 @@
export {
getMetricTitle,
GetMetricTitleInputSchema,
type GetMetricTitleInput,
} from './get-metric-title';

View File

@ -63,6 +63,10 @@
"./dictionary": {
"types": "./dist/dictionary/index.d.ts",
"default": "./dist/dictionary/index.js"
},
"./title": {
"types": "./dist/title/index.d.ts",
"default": "./dist/title/index.js"
}
},
"dependencies": {

View File

@ -0,0 +1,2 @@
export * from './requests';
export * from './responses';

View File

@ -0,0 +1,9 @@
import { z } from 'zod';
import { AssetTypeSchema } from '../assets/asset-types.types';
export const GetTitleRequestSchema = z.object({
assetId: z.string().uuid(),
assetType: AssetTypeSchema,
});
export type GetTitleRequest = z.infer<typeof GetTitleRequestSchema>;

View File

@ -0,0 +1,7 @@
import { z } from 'zod';
export const GetTitleResponseSchema = z.object({
title: z.string(),
});
export type GetTitleResponse = z.infer<typeof GetTitleResponseSchema>;