diff --git a/apps/server/src/api/v2/index.ts b/apps/server/src/api/v2/index.ts index c73a3a3e7..bd3315aa5 100644 --- a/apps/server/src/api/v2/index.ts +++ b/apps/server/src/api/v2/index.ts @@ -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; diff --git a/apps/server/src/api/v2/title/GET.ts b/apps/server/src/api/v2/title/GET.ts new file mode 100644 index 000000000..5a18c3127 --- /dev/null +++ b/apps/server/src/api/v2/title/GET.ts @@ -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; diff --git a/apps/server/src/api/v2/title/index.ts b/apps/server/src/api/v2/title/index.ts new file mode 100644 index 000000000..31d377c39 --- /dev/null +++ b/apps/server/src/api/v2/title/index.ts @@ -0,0 +1,3 @@ +import app from './GET'; + +export default app; diff --git a/packages/database/src/queries/assets/index.ts b/packages/database/src/queries/assets/index.ts index 8aa972df1..b14d13084 100644 --- a/packages/database/src/queries/assets/index.ts +++ b/packages/database/src/queries/assets/index.ts @@ -6,10 +6,4 @@ export { type GenerateAssetMessagesInput, } from './assets'; -export { - getChatDashboardFiles, - type DashboardFileContext, - type DashboardFile, -} from './dashboards'; - export type { DatabaseAssetType } from './assets'; diff --git a/packages/database/src/queries/chats/get-chat-title.ts b/packages/database/src/queries/chats/get-chat-title.ts new file mode 100644 index 000000000..e22cfe5be --- /dev/null +++ b/packages/database/src/queries/chats/get-chat-title.ts @@ -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; + +export async function getChatTitle(input: GetChatTitleInput): Promise { + 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; +} diff --git a/packages/database/src/queries/chats/index.ts b/packages/database/src/queries/chats/index.ts index 7e6a38b66..adb814cd9 100644 --- a/packages/database/src/queries/chats/index.ts +++ b/packages/database/src/queries/chats/index.ts @@ -12,3 +12,9 @@ export { type CreateMessageInput, type Chat, } from './chats'; + +export { + getChatTitle, + GetChatTitleInputSchema, + type GetChatTitleInput, +} from './get-chat-title'; diff --git a/packages/database/src/queries/collections/get-collection-title.ts b/packages/database/src/queries/collections/get-collection-title.ts new file mode 100644 index 000000000..a4722d300 --- /dev/null +++ b/packages/database/src/queries/collections/get-collection-title.ts @@ -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; + +// Updated return type to remove null since we now throw an error instead +export async function getCollectionTitle(input: GetCollectionTitleInput): Promise { + 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; +} diff --git a/packages/database/src/queries/collections/index.ts b/packages/database/src/queries/collections/index.ts new file mode 100644 index 000000000..1bc049c9e --- /dev/null +++ b/packages/database/src/queries/collections/index.ts @@ -0,0 +1,5 @@ +export { + getCollectionTitle, + GetCollectionTitleInputSchema, + type GetCollectionTitleInput, +} from './get-collection-title'; diff --git a/packages/database/src/queries/assets/dashboards.ts b/packages/database/src/queries/dashboards/dashboards.ts similarity index 100% rename from packages/database/src/queries/assets/dashboards.ts rename to packages/database/src/queries/dashboards/dashboards.ts diff --git a/packages/database/src/queries/dashboards/get-dashboard-title.ts b/packages/database/src/queries/dashboards/get-dashboard-title.ts new file mode 100644 index 000000000..7425c919b --- /dev/null +++ b/packages/database/src/queries/dashboards/get-dashboard-title.ts @@ -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; + +// Updated return type to remove null since we now throw an error instead +export async function getDashboardTitle(input: GetDashboardTitleInput): Promise { + 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; +} diff --git a/packages/database/src/queries/dashboards/index.ts b/packages/database/src/queries/dashboards/index.ts new file mode 100644 index 000000000..b284f5a8f --- /dev/null +++ b/packages/database/src/queries/dashboards/index.ts @@ -0,0 +1,11 @@ +export { + getChatDashboardFiles, + type DashboardFileContext, + type DashboardFile, +} from './dashboards'; + +export { + getDashboardTitle, + GetDashboardTitleInputSchema, + type GetDashboardTitleInput, +} from './get-dashboard-title'; diff --git a/packages/database/src/queries/index.ts b/packages/database/src/queries/index.ts index 278c06028..bc72287e2 100644 --- a/packages/database/src/queries/index.ts +++ b/packages/database/src/queries/index.ts @@ -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'; diff --git a/packages/database/src/queries/metrics/get-metric-title.ts b/packages/database/src/queries/metrics/get-metric-title.ts new file mode 100644 index 000000000..5fe548ac4 --- /dev/null +++ b/packages/database/src/queries/metrics/get-metric-title.ts @@ -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; + +// Updated return type to remove null since we now throw an error instead +export async function getMetricTitle(input: GetMetricTitleInput): Promise { + 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; +} diff --git a/packages/database/src/queries/metrics/index.ts b/packages/database/src/queries/metrics/index.ts new file mode 100644 index 000000000..ad8d701ae --- /dev/null +++ b/packages/database/src/queries/metrics/index.ts @@ -0,0 +1,5 @@ +export { + getMetricTitle, + GetMetricTitleInputSchema, + type GetMetricTitleInput, +} from './get-metric-title'; diff --git a/packages/server-shared/package.json b/packages/server-shared/package.json index d7ae6a8d8..4d9d8f8c4 100644 --- a/packages/server-shared/package.json +++ b/packages/server-shared/package.json @@ -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": { diff --git a/packages/server-shared/src/title/index.ts b/packages/server-shared/src/title/index.ts new file mode 100644 index 000000000..0b4b018e3 --- /dev/null +++ b/packages/server-shared/src/title/index.ts @@ -0,0 +1,2 @@ +export * from './requests'; +export * from './responses'; diff --git a/packages/server-shared/src/title/requests.ts b/packages/server-shared/src/title/requests.ts new file mode 100644 index 000000000..9bf6032b5 --- /dev/null +++ b/packages/server-shared/src/title/requests.ts @@ -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; diff --git a/packages/server-shared/src/title/responses.ts b/packages/server-shared/src/title/responses.ts new file mode 100644 index 000000000..decfedcba --- /dev/null +++ b/packages/server-shared/src/title/responses.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const GetTitleResponseSchema = z.object({ + title: z.string(), +}); + +export type GetTitleResponse = z.infer;