import { prisma } from "../../../db"; import { LangfuseConflictError, LangfuseNotFoundError, type OrderByState, } from "../../../"; import { CreateWidgetInput, WidgetDomain, WidgetListResponse, DashboardDomain, DashboardListResponse, DashboardDomainSchema, WidgetDomainSchema, DashboardDefinitionSchema, } from "./types"; import { z } from "zod/v4"; import { singleFilter } from "../../../"; export class DashboardService { /** * Retrieves a list of dashboards for a given project. */ public static async listDashboards(props: { projectId: string; limit?: number; page?: number; orderBy?: OrderByState; }): Promise { const { projectId, limit, page, orderBy } = props; const skip = page && limit ? (page - 1) * limit : undefined; const take = limit; const [dashboards, totalCount] = await Promise.all([ prisma.dashboard.findMany({ where: { OR: [{ projectId }, { projectId: null }], }, orderBy: orderBy ? [{ [orderBy.column]: orderBy.order.toLowerCase() }] : [{ updatedAt: "desc" }], skip, take, }), prisma.dashboard.count({ where: { OR: [{ projectId }, { projectId: null }], }, }), ]); const domainDashboards = dashboards.map((dashboard) => DashboardDomainSchema.parse({ ...dashboard, owner: dashboard.projectId ? "PROJECT" : "LANGFUSE", }), ); return { dashboards: domainDashboards, totalCount, }; } /** * Creates a new dashboard. */ public static async createDashboard( projectId: string, name: string, description: string, userId?: string, initialDefinition: z.infer = { widgets: [], }, ): Promise { const newDashboard = await prisma.dashboard.create({ data: { name, description, projectId, createdBy: userId, updatedBy: userId, definition: initialDefinition, }, }); return DashboardDomainSchema.parse({ ...newDashboard, owner: newDashboard.projectId ? "PROJECT" : "LANGFUSE", }); } /** * Updates a dashboard's definition. */ public static async updateDashboardDefinition( dashboardId: string, projectId: string, definition: z.infer, userId?: string, ): Promise { const updatedDashboard = await prisma.dashboard.update({ where: { id: dashboardId, projectId, }, data: { updatedBy: userId, definition: { widgets: definition.widgets.map((widget) => ({ type: "widget", id: widget.id, widgetId: widget.widgetId, x: widget.x, y: widget.y, x_size: widget.x_size, y_size: widget.y_size, })), }, }, }); return DashboardDomainSchema.parse({ ...updatedDashboard, owner: updatedDashboard.projectId ? "PROJECT" : "LANGFUSE", }); } /** * Updates a dashboard's name and description. */ public static async updateDashboard( dashboardId: string, projectId: string, name: string, description: string, userId?: string, ): Promise { const updatedDashboard = await prisma.dashboard.update({ where: { id: dashboardId, projectId, }, data: { name, description, updatedBy: userId, }, }); return DashboardDomainSchema.parse({ ...updatedDashboard, owner: updatedDashboard.projectId ? "PROJECT" : "LANGFUSE", }); } /** * Updates a dashboard's filters. */ public static async updateDashboardFilters( dashboardId: string, projectId: string, filters: z.infer[], userId?: string, ): Promise { const updatedDashboard = await prisma.dashboard.update({ where: { id: dashboardId, projectId, }, data: { updatedBy: userId, filters, }, }); return DashboardDomainSchema.parse({ ...updatedDashboard, owner: updatedDashboard.projectId ? "PROJECT" : "LANGFUSE", }); } /** * Gets a dashboard by ID. */ public static async getDashboard( dashboardId: string, projectId: string, ): Promise { const dashboard = await prisma.dashboard.findFirst({ where: { id: dashboardId, OR: [{ projectId }, { projectId: null }], }, }); if (!dashboard) { return null; } return DashboardDomainSchema.parse({ ...dashboard, owner: dashboard.projectId ? "PROJECT" : "LANGFUSE", }); } /** * Deletes a dashboard. */ public static async deleteDashboard( dashboardId: string, projectId: string, ): Promise { await prisma.dashboard.delete({ where: { id: dashboardId, projectId, }, }); } /** * Retrieves a list of dashboard widgets for a given project. */ public static async listWidgets(props: { projectId: string; limit?: number; page?: number; orderBy?: OrderByState; }): Promise { const { projectId, limit, page, orderBy } = props; const skip = page && limit ? (page - 1) * limit : undefined; const take = limit; const [widgets, totalCount] = await Promise.all([ prisma.dashboardWidget.findMany({ where: { projectId, }, orderBy: orderBy ? [{ [orderBy.column]: orderBy.order.toLowerCase() }] : [{ updatedAt: "desc" }], skip, take, }), prisma.dashboardWidget.count({ where: { projectId, }, }), ]); const domainWidgets = widgets.map((widget) => WidgetDomainSchema.parse({ ...widget, owner: widget.projectId ? "PROJECT" : "LANGFUSE", }), ); return { widgets: domainWidgets, totalCount, }; } /** * Creates a new dashboard widget. */ public static async createWidget( projectId: string, input: CreateWidgetInput, userId?: string, ): Promise { const newWidget = await prisma.dashboardWidget.create({ data: { name: input.name, description: input.description, projectId, view: input.view, dimensions: input.dimensions, metrics: input.metrics, filters: input.filters, chartType: input.chartType, chartConfig: input.chartConfig, createdBy: userId, updatedBy: userId, }, }); return WidgetDomainSchema.parse({ ...newWidget, owner: newWidget.projectId ? "PROJECT" : "LANGFUSE", }); } /** * Gets a dashboard widget by ID. Look either in the current project or in the Langfuse managed widgets. */ public static async getWidget( widgetId: string, projectId: string, ): Promise { const widget = await prisma.dashboardWidget.findFirst({ where: { id: widgetId, OR: [{ projectId }, { projectId: null }], }, }); if (!widget) { return null; } return WidgetDomainSchema.parse({ ...widget, owner: widget.projectId ? "PROJECT" : "LANGFUSE", }); } /** * Updates an existing dashboard widget. */ public static async updateWidget( projectId: string, widgetId: string, input: CreateWidgetInput, userId?: string, ): Promise { const updatedWidget = await prisma.dashboardWidget.update({ where: { id: widgetId, projectId, }, data: { name: input.name, description: input.description, view: input.view, dimensions: input.dimensions, metrics: input.metrics, filters: input.filters, chartType: input.chartType, chartConfig: input.chartConfig, updatedBy: userId, }, }); return WidgetDomainSchema.parse({ ...updatedWidget, owner: updatedWidget.projectId ? "PROJECT" : "LANGFUSE", }); } /** * Deletes a dashboard widget. * Throws an error if the widget is still referenced in any dashboard. */ public static async deleteWidget( widgetId: string, projectId: string, ): Promise { // First check if this widget is referenced in any dashboard definitions const referencingDashboards = await prisma.dashboard.findMany({ where: { projectId, definition: { path: ["widgets"], array_contains: [{ widgetId }], }, }, select: { id: true, name: true, }, }); if (referencingDashboards.length > 0) { const dashboardNames = referencingDashboards .map((d) => `"${d.name}"`) .join(", "); throw new LangfuseConflictError( `Cannot delete widget because it is still used in the following dashboards: ${dashboardNames}. Please remove the widget from these dashboards first.`, ); } // Delete the widget if it's not referenced await prisma.dashboardWidget.delete({ where: { id: widgetId, projectId, }, }); } /** * Copies a Langfuse-owned widget into the user project, rewires the specified dashboard placement to the new widget and returns the new widget id. */ public static async copyWidgetToProject(props: { sourceWidgetId: string; projectId: string; dashboardId: string; placementId: string; userId?: string; }): Promise { const { sourceWidgetId, projectId, dashboardId, placementId, userId } = props; const sourceWidget = await prisma.dashboardWidget.findFirst({ where: { id: sourceWidgetId, projectId: null, }, }); if (!sourceWidget) { throw new LangfuseNotFoundError( `Source widget ${sourceWidgetId} not found`, ); } // Duplicate widget and update dashboard definition atomically return prisma.$transaction(async (tx) => { // 1. create duplicate in project scope const newWidget = await tx.dashboardWidget.create({ data: { name: sourceWidget.name, description: sourceWidget.description, view: sourceWidget.view, dimensions: sourceWidget.dimensions ?? [], metrics: sourceWidget.metrics ?? [], filters: sourceWidget.filters ?? [], chartType: sourceWidget.chartType, chartConfig: sourceWidget.chartConfig ?? {}, projectId, // project owned createdBy: userId, updatedBy: userId, }, }); // 2. fetch dashboard to change reference const dashboard = await tx.dashboard.findFirst({ where: { id: dashboardId, projectId }, }); if (!dashboard) { throw new LangfuseNotFoundError( `Dashboard ${dashboardId} not found in project ${projectId}`, ); } const definition = (dashboard.definition ?? { widgets: [], }) as z.infer; const updatedWidgets = (definition.widgets || []).map((w: any) => w.id === placementId ? { ...w, widgetId: newWidget.id } : w, ); // 3. update dashboard with new widget reference await tx.dashboard.update({ where: { id: dashboardId, projectId }, data: { updatedBy: userId, definition: { widgets: updatedWidgets }, }, }); return newWidget.id; }); } }