diff --git a/apps/server/src/api/v2/dashboards/[id]/GET.ts b/apps/server/src/api/v2/dashboards/[id]/GET.ts index 8a8ab5c9c..0669d5c19 100644 --- a/apps/server/src/api/v2/dashboards/[id]/GET.ts +++ b/apps/server/src/api/v2/dashboards/[id]/GET.ts @@ -1,14 +1,10 @@ import { checkPermission } from '@buster/access-controls'; import { type User, - getAssetsAssociatedWithMetric, getCollectionsAssociatedWithDashboard, getDashboardById, - getMetricFileById, getOrganizationMemberCount, - getUser, getUsersWithDashboardPermissions, - getUsersWithMetricPermissions, } from '@buster/database/queries'; import { GetDashboardParamsSchema, @@ -16,18 +12,17 @@ import { type GetDashboardResponse, } from '@buster/server-shared/dashboards'; import type { DashboardYml } from '@buster/server-shared/dashboards'; -import { - DEFAULT_CHART_THEME, - type DataMetadata, - type GetMetricResponse, - type Metric, - type MetricYml, -} from '@buster/server-shared/metrics'; +import type { Metric } from '@buster/server-shared/metrics'; import type { VerificationStatus } from '@buster/server-shared/share'; import { zValidator } from '@hono/zod-validator'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import yaml from 'js-yaml'; +import { + buildMetricResponse, + fetchAndProcessMetricData, + getPubliclyEnabledByUser, +} from '../../../../shared-helpers/metric-helpers'; interface GetDashboardHandlerParams { dashboardId: string; @@ -201,7 +196,7 @@ async function getDashboardHandler( // Extract metric IDs from dashboard config const metricIds = extractMetricIds(resolvedContent); - const metrics = await getMetricsFromDashboardMetricIds(metricIds, user.id); + const metrics = await getMetricsFromDashboardMetricIds(metricIds, user); // Get the extra dashboard info concurrently const [individualPermissions, workspaceMemberCount, collections, publicEnabledBy] = @@ -266,140 +261,31 @@ function extractMetricIds(content: DashboardYml): string[] { } } -async function getPubliclyEnabledByUser(enabledById: string | null): Promise { - if (enabledById) { - const publicEnabledByUser = await getUser({ id: enabledById }); - return publicEnabledByUser.email; - } - return null; -} - async function getMetricsFromDashboardMetricIds( metricIds: string[], - userId: string + user: User ): Promise> { const metricsObj: Record = {}; - const promises: Promise[] = metricIds.map((metricId) => - getMetricFileForDashboard(metricId, userId) - ); - const metrics = await Promise.all(promises); - for (const metric of metrics) { - metricsObj[metric.id] = metric; + + // Process all metrics concurrently + const promises = metricIds.map(async (metricId) => { + const processedData = await fetchAndProcessMetricData(metricId, user, { + publicAccessPreviouslyVerified: true, // Access is inherited from dashboard access at a minimum + }); + + // Build the metric response + const metric = await buildMetricResponse(processedData, user.id); + return { metricId, metric }; + }); + + const results = await Promise.all(promises); + + // Filter out failed metrics and build the response object + for (const result of results) { + if (result) { + metricsObj[result.metricId] = result.metric; + } } + return metricsObj; } - -async function getMetricFileForDashboard(metricId: string, userId: string): Promise { - // Fetch metric details with permissions - const metricFile = await getMetricFileById(metricId); - - if (!metricFile) { - console.warn(`Metric file not found: ${metricId}`); - throw new HTTPException(404, { - message: 'Metric file not found', - }); - } - - let { effectiveRole } = await checkPermission({ - userId: userId, - assetId: metricId, - assetType: 'metric_file', - requiredRole: 'can_view', - organizationId: metricFile.organizationId, - workspaceSharing: metricFile.workspaceSharing || 'none', - }); - - // If user has no access, grant can_view because we have already checked dashboard public access - effectiveRole = effectiveRole || 'can_view'; - - // Parse version history - const versionHistory = metricFile.versionHistory; - const versions: Array<{ version_number: number; updated_at: string }> = []; - - Object.values(versionHistory).forEach((version) => { - versions.push({ - version_number: version.version_number, - updated_at: version.updated_at, - }); - }); - versions.sort((a, b) => a.version_number - b.version_number); - - // Use current/latest version - const content = metricFile.content as MetricYml; - const description = content.description; - const timeFrame = content.timeFrame; - const chartConfig = content.chartConfig; - const sql = content.sql; - const updatedAt = metricFile.updatedAt; - const versionNum = Math.max(...versions.map((v) => v.version_number), 1); - - // Color fallback was apart of v1 api logic - if (!chartConfig.colors) { - chartConfig.colors = DEFAULT_CHART_THEME; - } - - const fileYaml = yaml.dump(content); - - // Get the extra metric info concurrently - const [individualPermissions, workspaceMemberCount, associatedAssets, publicEnabledBy] = - await Promise.all([ - getUsersWithMetricPermissions({ metricId }), - getOrganizationMemberCount(metricFile.organizationId), - getAssetsAssociatedWithMetric(metricFile.id, userId), - getPubliclyEnabledByUser(metricFile.publiclyEnabledBy), - ]); - - const { dashboards, collections } = associatedAssets; - - // Not used but still exists in frontend code so including it here - const evaluationScore = (() => { - if (!metricFile.evaluationScore) { - return 'Low'; - } - if (metricFile.evaluationScore > 0.8) { - return 'High'; - } - if (metricFile.evaluationScore > 0.5) { - return 'Moderate'; - } - return 'Low'; - })(); - - // Build the response - const response: GetMetricResponse = { - id: metricFile.id, - type: 'metric_file', - name: metricFile.name, - version_number: versionNum, - error: null, - description, - file_name: metricFile.fileName, - time_frame: timeFrame, - data_source_id: metricFile.dataSourceId, - chart_config: chartConfig, - data_metadata: metricFile.dataMetadata as DataMetadata, - status: metricFile.verification as VerificationStatus, - file: fileYaml, - created_at: metricFile.createdAt, - updated_at: updatedAt, - sent_by_id: metricFile.createdBy, - sent_by_name: '', - sent_by_avatar_url: null, - sql, - dashboards, - collections, - versions, - evaluation_score: evaluationScore, - evaluation_summary: metricFile.evaluationSummary || '', - permission: effectiveRole, - individual_permissions: individualPermissions, - publicly_accessible: metricFile.publiclyAccessible, - public_expiry_date: metricFile.publicExpiryDate, - public_enabled_by: publicEnabledBy, - public_password: metricFile.publicPassword, - workspace_sharing: metricFile.workspaceSharing, - workspace_member_count: workspaceMemberCount, - }; - - return response; -} diff --git a/apps/server/src/api/v2/metric_files/[id]/GET.ts b/apps/server/src/api/v2/metric_files/[id]/GET.ts index ac96cb503..79bbfeead 100644 --- a/apps/server/src/api/v2/metric_files/[id]/GET.ts +++ b/apps/server/src/api/v2/metric_files/[id]/GET.ts @@ -1,24 +1,15 @@ -import { checkPermission } from '@buster/access-controls'; +import type { User } from '@buster/database/queries'; import { - type User, - getAssetsAssociatedWithMetric, - getMetricFileById, - getOrganizationMemberCount, - getUser, - getUsersWithMetricPermissions, -} from '@buster/database/queries'; -import { - DEFAULT_CHART_THEME, GetMetricParamsSchema, GetMetricQuerySchema, type GetMetricResponse, } from '@buster/server-shared/metrics'; -import type { ChartConfigProps, DataMetadata, MetricYml } from '@buster/server-shared/metrics'; -import type { VerificationStatus } from '@buster/server-shared/share'; import { zValidator } from '@hono/zod-validator'; import { Hono } from 'hono'; -import { HTTPException } from 'hono/http-exception'; -import yaml from 'js-yaml'; +import { + buildMetricResponse, + fetchAndProcessMetricData, +} from '../../../../shared-helpers/metric-helpers'; import { standardErrorHandler } from '../../../../utils/response'; interface GetMetricHandlerParams { @@ -67,212 +58,13 @@ async function getMetricHandler( ): Promise { const { metricId, versionNumber, password } = params; - // Fetch metric details with permissions - const metricFile = await getMetricFileById(metricId); - - if (!metricFile) { - console.warn(`Metric file not found: ${metricId}`); - throw new HTTPException(404, { - message: 'Metric file not found', - }); - } - - let { hasAccess, effectiveRole } = await checkPermission({ - userId: user.id, - assetId: metricId, - assetType: 'metric_file', - requiredRole: 'can_view', - organizationId: metricFile.organizationId, - workspaceSharing: metricFile.workspaceSharing || 'none', + // Use shared helper to fetch and process metric data + const processedData = await fetchAndProcessMetricData(metricId, user, { + publicAccessPreviouslyVerified: false, + password, + versionNumber, }); - // Check public access - if (!hasAccess) { - if (!metricFile.publiclyAccessible) { - console.warn(`Permission denied for user ${user.id} to metric ${metricId}`); - throw new HTTPException(403, { - message: "You don't have permission to view this metric", - }); - } - - // Check if public access has expired - const today = new Date(); - if (metricFile.publicExpiryDate && new Date(metricFile.publicExpiryDate) < today) { - console.warn(`Public access expired for metric ${metricId}`); - throw new HTTPException(403, { - message: 'Public access to this metric has expired', - }); - } - - // Check password if required - if (metricFile.publicPassword) { - if (!password) { - console.warn(`Public password required for metric ${metricId}`); - throw new HTTPException(418, { - message: 'Password required for public access', - }); - } - - if (password !== metricFile.publicPassword) { - console.warn(`Incorrect public password for metric ${metricId}`); - throw new HTTPException(403, { - message: 'Incorrect password for public access', - }); - } - } - - hasAccess = true; - effectiveRole = 'can_view'; - } - - if (!hasAccess || !effectiveRole) { - // This should never be hit because we have already checked for public access - console.warn(`Permission denied for user ${user.id} to metric ${metricId}`); - throw new HTTPException(403, { - message: "You don't have permission to view this metric", - }); - } - - // Resolve version-specific data - let resolvedContent: MetricYml; - let resolvedName: string; - let resolvedDescription: string | null; - let resolvedTimeFrame: string; - let resolvedChartConfig: ChartConfigProps; - let resolvedSql: string; - let resolvedUpdatedAt: string; - let resolvedVersionNum: number; - - // Parse version history - const versionHistory = metricFile.versionHistory; - const versions: Array<{ version_number: number; updated_at: string }> = []; - - Object.values(versionHistory).forEach((version) => { - versions.push({ - version_number: version.version_number, - updated_at: version.updated_at, - }); - }); - versions.sort((a, b) => a.version_number - b.version_number); - - // Get requested version if included in the request - if (versionNumber) { - const requestedVersionNumToString = versionNumber.toString(); - - if (versionHistory[requestedVersionNumToString]) { - const version = versionHistory[requestedVersionNumToString]; - - if (!version || !version.content) { - throw new HTTPException(404, { - message: `Version ${versionNumber} not found`, - }); - } - - const versionContent = version.content as MetricYml; - resolvedContent = versionContent; - resolvedName = versionContent.name; - resolvedDescription = versionContent.description; - resolvedTimeFrame = versionContent.timeFrame; - resolvedChartConfig = versionContent.chartConfig; - resolvedSql = versionContent.sql; - resolvedUpdatedAt = version.updated_at; - resolvedVersionNum = version.version_number; - } else { - throw new HTTPException(404, { - message: `Version ${versionNumber} not found`, - }); - } - } else { - // Use current/latest version - const currentContent = metricFile.content as MetricYml; - resolvedContent = currentContent; - resolvedName = metricFile.name; - resolvedDescription = currentContent.description; - resolvedTimeFrame = currentContent.timeFrame; - resolvedChartConfig = currentContent.chartConfig; - resolvedSql = currentContent.sql; - resolvedUpdatedAt = metricFile.updatedAt; - - // Determine latest version number - const maxVersion = Math.max(...versions.map((v) => v.version_number), 1); - resolvedVersionNum = maxVersion; - } - - // Color fallback was apart of v1 api logic - if (!resolvedChartConfig.colors) { - resolvedChartConfig.colors = DEFAULT_CHART_THEME; - } - - const fileYaml = yaml.dump(resolvedContent); - - // Get the extra metric info concurrently - const [individualPermissions, workspaceMemberCount, associatedAssets, publicEnabledBy] = - await Promise.all([ - getUsersWithMetricPermissions({ metricId }), - getOrganizationMemberCount(metricFile.organizationId), - getAssetsAssociatedWithMetric(metricFile.id, user.id), - getPubliclyEnabledByUser(metricFile.publiclyEnabledBy), - ]); - - const { dashboards, collections } = associatedAssets; - - // Not used but still exists in frontend code so including it here - const evaluationScore = (() => { - if (!metricFile.evaluationScore) { - return 'Low'; - } - if (metricFile.evaluationScore > 0.8) { - return 'High'; - } - if (metricFile.evaluationScore > 0.5) { - return 'Moderate'; - } - return 'Low'; - })(); - - // Build the response - const response: GetMetricResponse = { - id: metricFile.id, - type: 'metric_file', - name: resolvedName, - version_number: resolvedVersionNum, - error: null, - description: resolvedDescription, - file_name: metricFile.fileName, - time_frame: resolvedTimeFrame, - data_source_id: metricFile.dataSourceId, - chart_config: resolvedChartConfig, - data_metadata: metricFile.dataMetadata as DataMetadata, - status: metricFile.verification as VerificationStatus, - file: fileYaml, - created_at: metricFile.createdAt, - updated_at: resolvedUpdatedAt, - sent_by_id: metricFile.createdBy, - sent_by_name: '', - sent_by_avatar_url: null, - sql: resolvedSql, - dashboards, - collections, - versions, - evaluation_score: evaluationScore, - evaluation_summary: metricFile.evaluationSummary || '', - permission: effectiveRole, - individual_permissions: individualPermissions, - publicly_accessible: metricFile.publiclyAccessible, - public_expiry_date: metricFile.publicExpiryDate, - public_enabled_by: publicEnabledBy, - public_password: metricFile.publicPassword, - workspace_sharing: metricFile.workspaceSharing, - workspace_member_count: workspaceMemberCount, - }; - - return response; -} - -async function getPubliclyEnabledByUser(enabledById: string | null): Promise { - if (enabledById) { - const publicEnabledByUser = await getUser({ id: enabledById }); - return publicEnabledByUser.email; - } - return null; + // Build and return the complete metric response + return await buildMetricResponse(processedData, user.id); } diff --git a/apps/server/src/shared-helpers/metric-helpers.ts b/apps/server/src/shared-helpers/metric-helpers.ts new file mode 100644 index 000000000..0c7931725 --- /dev/null +++ b/apps/server/src/shared-helpers/metric-helpers.ts @@ -0,0 +1,310 @@ +import { checkPermission } from '@buster/access-controls'; +import { + type MetricFile, + type User, + getAssetsAssociatedWithMetric, + getMetricFileById, + getOrganizationMemberCount, + getUser, + getUsersWithMetricPermissions, +} from '@buster/database/queries'; +import { + type ChartConfigProps, + DEFAULT_CHART_THEME, + type DataMetadata, + type GetMetricResponse, + type MetricYml, +} from '@buster/server-shared/metrics'; +import type { AssetPermissionRole, VerificationStatus } from '@buster/server-shared/share'; +import { HTTPException } from 'hono/http-exception'; +import yaml from 'js-yaml'; +import { z } from 'zod'; + +export const MetricAccessOptionsSchema = z.object({ + /** If public access has been verified by a parent resource set to true */ + publicAccessPreviouslyVerified: z.boolean().default(false), + /** Password for public access validation */ + password: z.string().optional(), + /** Version number to fetch */ + versionNumber: z.number().int().optional(), +}); + +export type MetricAccessOptions = z.infer; + +export interface ProcessedMetricData { + metricFile: MetricFile; + resolvedContent: MetricYml; + resolvedName: string; + resolvedDescription: string | null; + resolvedTimeFrame: string; + resolvedChartConfig: ChartConfigProps; + resolvedSql: string; + resolvedUpdatedAt: string; + resolvedVersionNum: number; + effectiveRole: AssetPermissionRole; + versions: Array<{ version_number: number; updated_at: string }>; +} + +/** + * Shared helper function to fetch and process metric data with flexible access control + */ +export async function fetchAndProcessMetricData( + metricId: string, + user: User, + options: MetricAccessOptions +): Promise { + const { publicAccessPreviouslyVerified = false, password, versionNumber } = options; + + // Fetch metric details + const metricFile = await getMetricFileById(metricId); + + if (!metricFile) { + console.warn(`Metric file not found: ${metricId}`); + throw new HTTPException(404, { + message: 'Metric file not found', + }); + } + + let effectiveRole: AssetPermissionRole | undefined = publicAccessPreviouslyVerified + ? 'can_view' + : undefined; + + const permissionResult = await checkPermission({ + userId: user.id, + assetId: metricId, + assetType: 'metric_file', + requiredRole: 'can_view', + organizationId: metricFile.organizationId, + workspaceSharing: metricFile.workspaceSharing || 'none', + }); + + effectiveRole = permissionResult.effectiveRole ? permissionResult.effectiveRole : effectiveRole; + + // Check public access if needed + if (!effectiveRole) { + if (!metricFile.publiclyAccessible) { + console.warn(`Permission denied for user ${user.id} to metric ${metricId}`); + throw new HTTPException(403, { + message: "You don't have permission to view this metric", + }); + } + + // Check if public access has expired + const today = new Date(); + if (metricFile.publicExpiryDate && new Date(metricFile.publicExpiryDate) < today) { + console.warn(`Public access expired for metric ${metricId}`); + throw new HTTPException(403, { + message: 'Public access to this metric has expired', + }); + } + + // Check password if required + if (metricFile.publicPassword) { + if (!password) { + console.warn(`Public password required for metric ${metricId}`); + throw new HTTPException(418, { + message: 'Password required for public access', + }); + } + + if (password !== metricFile.publicPassword) { + console.warn(`Incorrect public password for metric ${metricId}`); + throw new HTTPException(403, { + message: 'Incorrect password for public access', + }); + } + } + + effectiveRole = 'can_view'; + } + + // Shouldn't ever be hit because we throw errors above if public access is not verified + if (!effectiveRole) { + console.warn(`Permission denied for user ${user.id} to metric ${metricId}`); + throw new HTTPException(403, { + message: "You don't have permission to view this metric", + }); + } + + // Parse version history + const versionHistory = metricFile.versionHistory; + const versions: Array<{ version_number: number; updated_at: string }> = []; + + Object.values(versionHistory).forEach((version) => { + versions.push({ + version_number: version.version_number, + updated_at: version.updated_at, + }); + }); + versions.sort((a, b) => a.version_number - b.version_number); + + // Resolve version-specific data + let resolvedContent: MetricYml; + let resolvedName: string; + let resolvedDescription: string | null; + let resolvedTimeFrame: string; + let resolvedChartConfig: ChartConfigProps; + let resolvedSql: string; + let resolvedUpdatedAt: string; + let resolvedVersionNum: number; + + // Get requested version if included in the request + if (versionNumber) { + const requestedVersionNumToString = versionNumber.toString(); + + if (versionHistory[requestedVersionNumToString]) { + const version = versionHistory[requestedVersionNumToString]; + + if (!version || !version.content) { + throw new HTTPException(404, { + message: `Version ${versionNumber} not found`, + }); + } + + const versionContent = version.content as MetricYml; + resolvedContent = versionContent; + resolvedName = versionContent.name; + resolvedDescription = versionContent.description; + resolvedTimeFrame = versionContent.timeFrame; + resolvedChartConfig = versionContent.chartConfig; + resolvedSql = versionContent.sql; + resolvedUpdatedAt = version.updated_at; + resolvedVersionNum = version.version_number; + } else { + throw new HTTPException(404, { + message: `Version ${versionNumber} not found`, + }); + } + } else { + // Use current/latest version + const currentContent = metricFile.content as MetricYml; + resolvedContent = currentContent; + resolvedName = metricFile.name; + resolvedDescription = currentContent.description; + resolvedTimeFrame = currentContent.timeFrame; + resolvedChartConfig = currentContent.chartConfig; + resolvedSql = currentContent.sql; + resolvedUpdatedAt = metricFile.updatedAt; + + // Determine latest version number + const maxVersion = Math.max(...versions.map((v) => v.version_number), 1); + resolvedVersionNum = maxVersion; + } + + // Color fallback from v1 api logic + if (!resolvedChartConfig.colors) { + resolvedChartConfig.colors = DEFAULT_CHART_THEME; + } + + return { + metricFile, + resolvedContent, + resolvedName, + resolvedDescription, + resolvedTimeFrame, + resolvedChartConfig, + resolvedSql, + resolvedUpdatedAt, + resolvedVersionNum, + effectiveRole, + versions, + }; +} + +/** + * Build a complete metric response from processed metric data + */ +export async function buildMetricResponse( + processedData: ProcessedMetricData, + userId: string +): Promise { + const { + metricFile, + resolvedContent, + resolvedName, + resolvedDescription, + resolvedTimeFrame, + resolvedChartConfig, + resolvedSql, + resolvedUpdatedAt, + resolvedVersionNum, + effectiveRole, + versions, + } = processedData; + + const fileYaml = yaml.dump(resolvedContent); + + // Get the extra metric info concurrently + const [individualPermissions, workspaceMemberCount, associatedAssets, publicEnabledBy] = + await Promise.all([ + getUsersWithMetricPermissions({ metricId: metricFile.id }), + getOrganizationMemberCount(metricFile.organizationId), + getAssetsAssociatedWithMetric(metricFile.id, userId), + getPubliclyEnabledByUser(metricFile.publiclyEnabledBy), + ]); + + const { dashboards, collections } = associatedAssets; + + // Not used but still exists in frontend code so including it here + const evaluationScore = (() => { + if (!metricFile.evaluationScore) { + return 'Low'; + } + if (metricFile.evaluationScore > 0.8) { + return 'High'; + } + if (metricFile.evaluationScore > 0.5) { + return 'Moderate'; + } + return 'Low'; + })() as 'High' | 'Moderate' | 'Low'; + + // Build the response + const response: GetMetricResponse = { + id: metricFile.id, + type: 'metric_file', + name: resolvedName, + version_number: resolvedVersionNum, + error: null, + description: resolvedDescription, + file_name: metricFile.fileName, + time_frame: resolvedTimeFrame, + data_source_id: metricFile.dataSourceId, + chart_config: resolvedChartConfig, + data_metadata: metricFile.dataMetadata as DataMetadata, + status: metricFile.verification as VerificationStatus, + file: fileYaml, + created_at: metricFile.createdAt, + updated_at: resolvedUpdatedAt, + sent_by_id: metricFile.createdBy, + sent_by_name: '', + sent_by_avatar_url: null, + sql: resolvedSql, + dashboards, + collections, + versions, + evaluation_score: evaluationScore, + evaluation_summary: metricFile.evaluationSummary || '', + permission: effectiveRole, + individual_permissions: individualPermissions, + publicly_accessible: metricFile.publiclyAccessible, + public_expiry_date: metricFile.publicExpiryDate, + public_enabled_by: publicEnabledBy, + public_password: metricFile.publicPassword, + workspace_sharing: metricFile.workspaceSharing, + workspace_member_count: workspaceMemberCount, + }; + + return response; +} + +/** + * Helper to get publicly enabled by user email + */ +export async function getPubliclyEnabledByUser(enabledById: string | null): Promise { + if (enabledById) { + const publicEnabledByUser = await getUser({ id: enabledById }); + return publicEnabledByUser.email; + } + return null; +}