feat: (dashboards) crud charts/dashboard server actions (#7307)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Theodór Tómas
2026-02-23 13:14:26 +07:00
committed by GitHub
parent 62aa186a81
commit 5ccb4af249
18 changed files with 2248 additions and 3 deletions
@@ -22,6 +22,9 @@ export type AuditLoggingCtx = {
quotaId?: string;
teamId?: string;
integrationId?: string;
chartId?: string;
dashboardId?: string;
dashboardWidgetId?: string;
};
export type ActionClientCtx = {
@@ -12,7 +12,9 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
| Prisma.ContactAttributeKeyFindManyArgs
| Prisma.ChartFindManyArgs
| Prisma.DashboardFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { executeQuery } from "./cube-client";
const mockLoad = vi.fn();
const mockTablePivot = vi.fn();
@@ -13,12 +12,14 @@ vi.mock("@cubejs-client/core", () => ({
describe("executeQuery", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
const resultSet = { tablePivot: mockTablePivot };
mockLoad.mockResolvedValue(resultSet);
mockTablePivot.mockReturnValue([{ id: "1", count: 42 }]);
});
test("loads query and returns tablePivot result", async () => {
const { executeQuery } = await import("./cube-client");
const query = { measures: ["FeedbackRecords.count"] };
const result = await executeQuery(query);
@@ -26,4 +27,17 @@ describe("executeQuery", () => {
expect(mockTablePivot).toHaveBeenCalled();
expect(result).toEqual([{ id: "1", count: 42 }]);
});
test("preserves API URL when it already contains /cubejs-api/v1", async () => {
const fullUrl = "https://cube.example.com/cubejs-api/v1";
vi.stubEnv("CUBEJS_API_URL", fullUrl);
const { executeQuery } = await import("./cube-client");
await executeQuery({ measures: ["FeedbackRecords.count"] });
// eslint-disable-next-line @typescript-eslint/no-require-imports
const cubejs = ((await vi.importMock("@cubejs-client/core")) as any).default;
expect(cubejs).toHaveBeenCalledWith(expect.any(String), { apiUrl: fullUrl });
vi.unstubAllEnvs();
});
});
@@ -12,6 +12,7 @@ let cubeClient: CubeApi | null = null;
function getCubeClient(): CubeApi {
if (!cubeClient) {
// TODO: This will fail silently if the token is not set. We need to fix this before going to production.
const token = process.env.CUBEJS_API_TOKEN ?? "";
cubeClient = cubejs(token, { apiUrl: getApiUrl() });
}
@@ -0,0 +1,196 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import {
createChart,
deleteChart,
duplicateChart,
getChart,
getCharts,
updateChart,
} from "@/modules/ee/analysis/charts/lib/charts";
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZChartCreateInput, ZChartUpdateInput } from "../types/analysis";
const ZCreateChartAction = z.object({
environmentId: ZId,
chartInput: ZChartCreateInput,
});
export const createChartAction = authenticatedActionClient.schema(ZCreateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await createChart(parsedInput.chartInput);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = chart.id;
ctx.auditLoggingCtx.newObject = chart;
return chart;
}
)
);
const ZUpdateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
chartUpdateInput: ZChartUpdateInput,
});
export const updateChartAction = authenticatedActionClient.schema(ZUpdateChartAction).action(
withAuditLogging(
"updated",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const { chart, updatedChart } = await updateChart(
parsedInput.chartId,
projectId,
parsedInput.chartUpdateInput
);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
ctx.auditLoggingCtx.oldObject = chart;
ctx.auditLoggingCtx.newObject = updatedChart;
return updatedChart;
}
)
);
const ZDuplicateChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const duplicateChartAction = authenticatedActionClient.schema(ZDuplicateChartAction).action(
withAuditLogging(
"created",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDuplicateChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const duplicatedChart = await duplicateChart(parsedInput.chartId, projectId, ctx.user.id);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = duplicatedChart.id;
ctx.auditLoggingCtx.newObject = duplicatedChart;
return duplicatedChart;
}
)
);
const ZDeleteChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const deleteChartAction = authenticatedActionClient.schema(ZDeleteChartAction).action(
withAuditLogging(
"deleted",
"chart",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteChartAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const chart = await deleteChart(parsedInput.chartId, projectId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.chartId = parsedInput.chartId;
ctx.auditLoggingCtx.oldObject = chart;
return { success: true };
}
)
);
const ZGetChartAction = z.object({
environmentId: ZId,
chartId: ZId,
});
export const getChartAction = authenticatedActionClient
.schema(ZGetChartAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getChart(parsedInput.chartId, projectId);
}
);
const ZGetChartsAction = z.object({
environmentId: ZId,
});
export const getChartsAction = authenticatedActionClient
.schema(ZGetChartsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetChartsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getCharts(projectId);
}
);
@@ -0,0 +1,383 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
vi.mock("server-only", () => ({}));
var mockTxChart: {
// NOSONAR / test code
findFirst: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
vi.mock("@formbricks/database", () => {
const tx = {
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
mockTxChart = tx;
return {
prisma: {
chart: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn((cb: any) => cb({ chart: tx })),
},
};
});
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
const mockChartId = "chart-abc-123";
const mockProjectId = "project-abc-123";
const mockUserId = "user-abc-123";
const selectChart = {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
};
const mockChart = {
id: mockChartId,
name: "Test Chart",
type: "bar",
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
};
const makePrismaError = (code: string) =>
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
describe("Chart Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createChart", () => {
test("creates a chart successfully", async () => {
vi.mocked(prisma.chart.create).mockResolvedValue(mockChart as any);
const { createChart } = await import("./charts");
const result = await createChart({
projectId: mockProjectId,
name: "Test Chart",
type: "bar",
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdBy: mockUserId,
});
expect(result).toEqual(mockChart);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: {
name: "Test Chart",
type: "bar",
projectId: mockProjectId,
query: { measures: ["Responses.count"] },
config: { showLegend: true },
createdBy: mockUserId,
},
select: selectChart,
});
});
test("throws InvalidInputError on unique constraint violation", async () => {
vi.mocked(prisma.chart.create).mockRejectedValue(
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
);
const { createChart } = await import("./charts");
await expect(
createChart({
projectId: mockProjectId,
name: "Duplicate",
type: "bar",
query: {},
config: {},
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
test("throws DatabaseError on other Prisma errors", async () => {
vi.mocked(prisma.chart.create).mockRejectedValue(makePrismaError("P9999"));
const { createChart } = await import("./charts");
await expect(
createChart({
projectId: mockProjectId,
name: "Test",
type: "bar",
query: {},
config: {},
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("updateChart", () => {
test("updates a chart successfully", async () => {
const updatedChart = { ...mockChart, name: "Updated Chart" };
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.update.mockResolvedValue(updatedChart);
const { updateChart } = await import("./charts");
const result = await updateChart(mockChartId, mockProjectId, { name: "Updated Chart" });
expect(result).toEqual({ chart: mockChart, updatedChart });
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(mockTxChart.update).toHaveBeenCalledWith({
where: { id: mockChartId },
data: { name: "Updated Chart", type: undefined, query: undefined, config: undefined },
select: selectChart,
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
const { updateChart } = await import("./charts");
await expect(updateChart(mockChartId, mockProjectId, { name: "Updated" })).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxChart.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
const { updateChart } = await import("./charts");
await expect(updateChart(mockChartId, mockProjectId, { name: "Taken Name" })).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
describe("duplicateChart", () => {
test("duplicates a chart with '(copy)' suffix", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
vi.mocked(prisma.chart.create).mockResolvedValue({ ...mockChart, name: "Test Chart (copy)" } as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy)" }),
select: selectChart,
});
});
test("increments copy number when '(copy)' already exists", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 2)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy 2)" }),
select: selectChart,
});
});
test("finds next available copy number", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([
{ name: "Test Chart (copy)" },
{ name: "Test Chart (copy 2)" },
] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 3)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.create).toHaveBeenCalledWith({
data: expect.objectContaining({ name: "Test Chart (copy 3)" }),
select: selectChart,
});
});
test("strips existing copy suffix before generating new name", async () => {
const chartWithCopy = { ...mockChart, name: "Test Chart (copy)" };
vi.mocked(prisma.chart.findFirst).mockResolvedValue(chartWithCopy as any);
vi.mocked(prisma.chart.findMany).mockResolvedValue([{ name: "Test Chart (copy)" }] as any);
vi.mocked(prisma.chart.create).mockResolvedValue({
...mockChart,
name: "Test Chart (copy 2)",
} as any);
const { duplicateChart } = await import("./charts");
await duplicateChart(mockChartId, mockProjectId, mockUserId);
expect(prisma.chart.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId, name: { startsWith: "Test Chart (copy" } },
select: { name: true },
});
});
test("throws ResourceNotFoundError when source chart does not exist", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
const { duplicateChart } = await import("./charts");
await expect(duplicateChart(mockChartId, mockProjectId, mockUserId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
});
});
describe("deleteChart", () => {
test("deletes a chart successfully", async () => {
mockTxChart.findFirst.mockResolvedValue(mockChart);
mockTxChart.delete.mockResolvedValue(undefined);
const { deleteChart } = await import("./charts");
const result = await deleteChart(mockChartId, mockProjectId);
expect(result).toEqual(mockChart);
expect(mockTxChart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
expect(mockTxChart.delete).toHaveBeenCalledWith({ where: { id: mockChartId } });
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
const { deleteChart } = await import("./charts");
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxChart.delete).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma errors", async () => {
mockTxChart.findFirst.mockRejectedValue(makePrismaError("P9999"));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) => cb({ chart: mockTxChart }));
const { deleteChart } = await import("./charts");
await expect(deleteChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getChart", () => {
test("returns a chart successfully", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(mockChart as any);
const { getChart } = await import("./charts");
const result = await getChart(mockChartId, mockProjectId);
expect(result).toEqual(mockChart);
expect(prisma.chart.findFirst).toHaveBeenCalledWith({
where: { id: mockChartId, projectId: mockProjectId },
select: selectChart,
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
vi.mocked(prisma.chart.findFirst).mockResolvedValue(null);
const { getChart } = await import("./charts");
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.chart.findFirst).mockRejectedValue(makePrismaError("P9999"));
const { getChart } = await import("./charts");
await expect(getChart(mockChartId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getCharts", () => {
test("returns all charts for a project", async () => {
const charts = [
{ ...mockChart, widgets: [{ dashboardId: "dash-1" }] },
{ ...mockChart, id: "chart-2", name: "Chart 2", widgets: [] },
];
vi.mocked(prisma.chart.findMany).mockResolvedValue(charts as any);
const { getCharts } = await import("./charts");
const result = await getCharts(mockProjectId);
expect(result).toEqual(charts);
expect(prisma.chart.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId },
orderBy: { createdAt: "desc" },
select: expect.objectContaining({
id: true,
name: true,
widgets: { select: { dashboardId: true } },
}),
});
});
test("returns empty array when no charts exist", async () => {
vi.mocked(prisma.chart.findMany).mockResolvedValue([]);
const { getCharts } = await import("./charts");
const result = await getCharts(mockProjectId);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.chart.findMany).mockRejectedValue(makePrismaError("P9999"));
const { getCharts } = await import("./charts");
await expect(getCharts(mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
});
@@ -0,0 +1,246 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { ZChartConfig, ZChartQuery } from "@formbricks/types/dashboard";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import {
TChart,
TChartCreateInput,
TChartUpdateInput,
TChartWithWidgets,
ZChartCreateInput,
ZChartType,
ZChartUpdateInput,
} from "@/modules/ee/analysis/types/analysis";
export const selectChart = {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
} as const;
export const createChart = async (data: TChartCreateInput): Promise<TChart> => {
validateInputs([data, ZChartCreateInput]);
try {
return await prisma.chart.create({
data: {
name: data.name,
type: data.type,
projectId: data.projectId,
query: data.query,
config: data.config,
createdBy: data.createdBy,
},
select: selectChart,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A chart with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateChart = async (
chartId: string,
projectId: string,
data: TChartUpdateInput
): Promise<{ chart: TChart; updatedChart: TChart }> => {
validateInputs([chartId, ZId], [projectId, ZId], [data, ZChartUpdateInput]);
try {
return await prisma.$transaction(async (tx) => {
const chart = await tx.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
const updatedChart = await tx.chart.update({
where: { id: chartId },
data: {
name: data.name,
type: data.type,
query: data.query,
config: data.config,
},
select: selectChart,
});
return { chart, updatedChart };
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A chart with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
const getUniqueCopyName = async (baseName: string, projectId: string): Promise<string> => {
const stripped = baseName.replace(/ \(copy(?: \d+)?\)$/, "");
try {
const existing = await prisma.chart.findMany({
where: {
projectId,
name: { startsWith: `${stripped} (copy` },
},
select: { name: true },
});
const existingNames = new Set(existing.map((c) => c.name));
const firstCandidate = `${stripped} (copy)`;
if (!existingNames.has(firstCandidate)) {
return firstCandidate;
}
let n = 2;
while (existingNames.has(`${stripped} (copy ${n})`)) {
n++;
}
return `${stripped} (copy ${n})`;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const duplicateChart = async (
chartId: string,
projectId: string,
createdBy: string
): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId], [createdBy, ZId]);
try {
const sourceChart = await prisma.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!sourceChart) {
throw new ResourceNotFoundError("Chart", chartId);
}
const uniqueName = await getUniqueCopyName(sourceChart.name, projectId);
return await createChart({
projectId,
name: uniqueName,
type: ZChartType.parse(sourceChart.type),
query: ZChartQuery.parse(sourceChart.query),
config: ZChartConfig.parse(sourceChart.config ?? {}),
createdBy,
});
} catch (error) {
if (error instanceof ResourceNotFoundError || error instanceof InvalidInputError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteChart = async (chartId: string, projectId: string): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const chart = await tx.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
await tx.chart.delete({
where: { id: chartId },
});
return chart;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getChart = async (chartId: string, projectId: string): Promise<TChart> => {
validateInputs([chartId, ZId], [projectId, ZId]);
try {
const chart = await prisma.chart.findFirst({
where: { id: chartId, projectId },
select: selectChart,
});
if (!chart) {
throw new ResourceNotFoundError("Chart", chartId);
}
return chart;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getCharts = async (projectId: string): Promise<TChartWithWidgets[]> => {
validateInputs([projectId, ZId]);
try {
return await prisma.chart.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
...selectChart,
widgets: {
select: { dashboardId: true },
},
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,212 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZWidgetLayout } from "@formbricks/types/dashboard";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
import { checkProjectAccess } from "@/modules/ee/analysis/lib/access";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { ZDashboardUpdateInput } from "../types/analysis";
import {
addChartToDashboard,
createDashboard,
deleteDashboard,
getDashboard,
getDashboards,
updateDashboard,
} from "./lib/dashboards";
const ZCreateDashboardAction = z.object({
environmentId: ZId,
name: z.string().min(1),
description: z.string().optional(),
});
export const createDashboardAction = authenticatedActionClient.schema(ZCreateDashboardAction).action(
withAuditLogging(
"created",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZCreateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await createDashboard({
projectId,
name: parsedInput.name,
description: parsedInput.description,
createdBy: ctx.user.id,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = dashboard.id;
ctx.auditLoggingCtx.newObject = dashboard;
return dashboard;
}
)
);
const ZUpdateDashboardAction = z
.object({
environmentId: ZId,
dashboardId: ZId,
})
.merge(ZDashboardUpdateInput);
export const updateDashboardAction = authenticatedActionClient.schema(ZUpdateDashboardAction).action(
withAuditLogging(
"updated",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZUpdateDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const { dashboard, updatedDashboard } = await updateDashboard(parsedInput.dashboardId, projectId, {
name: parsedInput.name,
description: parsedInput.description,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
ctx.auditLoggingCtx.oldObject = dashboard;
ctx.auditLoggingCtx.newObject = updatedDashboard;
return updatedDashboard;
}
)
);
const ZDeleteDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const deleteDashboardAction = authenticatedActionClient.schema(ZDeleteDashboardAction).action(
withAuditLogging(
"deleted",
"dashboard",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZDeleteDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const dashboard = await deleteDashboard(parsedInput.dashboardId, projectId);
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardId = parsedInput.dashboardId;
ctx.auditLoggingCtx.oldObject = dashboard;
return { success: true };
}
)
);
const ZGetDashboardsAction = z.object({
environmentId: ZId,
});
export const getDashboardsAction = authenticatedActionClient
.schema(ZGetDashboardsAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardsAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getDashboards(projectId);
}
);
const ZGetDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
});
export const getDashboardAction = authenticatedActionClient
.schema(ZGetDashboardAction)
.action(
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZGetDashboardAction>;
}) => {
const { projectId } = await checkProjectAccess(ctx.user.id, parsedInput.environmentId, "read");
return getDashboard(parsedInput.dashboardId, projectId);
}
);
const ZAddChartToDashboardAction = z.object({
environmentId: ZId,
dashboardId: ZId,
chartId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout.optional().default({ x: 0, y: 0, w: 4, h: 3 }),
});
export const addChartToDashboardAction = authenticatedActionClient.schema(ZAddChartToDashboardAction).action(
withAuditLogging(
"created",
"dashboardWidget",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: z.infer<typeof ZAddChartToDashboardAction>;
}) => {
const { organizationId, projectId } = await checkProjectAccess(
ctx.user.id,
parsedInput.environmentId,
"readWrite"
);
const widget = await addChartToDashboard({
dashboardId: parsedInput.dashboardId,
chartId: parsedInput.chartId,
projectId,
title: parsedInput.title,
layout: parsedInput.layout,
});
ctx.auditLoggingCtx.organizationId = organizationId;
ctx.auditLoggingCtx.projectId = projectId;
ctx.auditLoggingCtx.dashboardWidgetId = widget.id;
ctx.auditLoggingCtx.newObject = widget;
return widget;
}
)
);
@@ -0,0 +1,474 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
vi.mock("server-only", () => ({}));
var mockTxDashboard: {
// NOSONAR / test code
findFirst: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
};
var mockTxChart: { findFirst: ReturnType<typeof vi.fn> }; // NOSONAR / test code
var mockTxWidget: {
// NOSONAR / test code
aggregate: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
vi.mock("@formbricks/database", () => {
const txDash = { findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() };
const txChart = { findFirst: vi.fn() };
const txWidget = { aggregate: vi.fn(), create: vi.fn() };
mockTxDashboard = txDash;
mockTxChart = txChart;
mockTxWidget = txWidget;
return {
prisma: {
dashboard: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
},
$transaction: vi.fn((cb: any) => cb({ dashboard: txDash, chart: txChart, dashboardWidget: txWidget })),
},
};
});
vi.mock("@/lib/utils/validate", () => ({
validateInputs: vi.fn(),
}));
vi.mock("@/modules/ee/analysis/charts/lib/charts", () => ({
selectChart: {
id: true,
name: true,
type: true,
query: true,
config: true,
createdAt: true,
updatedAt: true,
},
}));
const mockDashboardId = "dashboard-abc-123";
const mockProjectId = "project-abc-123";
const mockUserId = "user-abc-123";
const mockChartId = "chart-abc-123";
const selectDashboard = {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
};
const mockDashboard = {
id: mockDashboardId,
name: "Test Dashboard",
description: "A test dashboard",
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
};
const makePrismaError = (code: string) =>
new Prisma.PrismaClientKnownRequestError("mock error", { code, clientVersion: "5.0.0" });
describe("Dashboard Service", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createDashboard", () => {
test("creates a dashboard successfully", async () => {
vi.mocked(prisma.dashboard.create).mockResolvedValue(mockDashboard as any);
const { createDashboard } = await import("./dashboards");
const result = await createDashboard({
projectId: mockProjectId,
name: "Test Dashboard",
description: "A test dashboard",
createdBy: mockUserId,
});
expect(result).toEqual(mockDashboard);
expect(prisma.dashboard.create).toHaveBeenCalledWith({
data: {
name: "Test Dashboard",
description: "A test dashboard",
projectId: mockProjectId,
createdBy: mockUserId,
},
select: selectDashboard,
});
});
test("creates a dashboard without description", async () => {
const dashboardNoDesc = { ...mockDashboard, description: undefined };
vi.mocked(prisma.dashboard.create).mockResolvedValue(dashboardNoDesc as any);
const { createDashboard } = await import("./dashboards");
const result = await createDashboard({
projectId: mockProjectId,
name: "Test Dashboard",
createdBy: mockUserId,
});
expect(result).toEqual(dashboardNoDesc);
expect(prisma.dashboard.create).toHaveBeenCalledWith({
data: {
name: "Test Dashboard",
description: undefined,
projectId: mockProjectId,
createdBy: mockUserId,
},
select: selectDashboard,
});
});
test("throws InvalidInputError on unique constraint violation", async () => {
vi.mocked(prisma.dashboard.create).mockRejectedValue(
makePrismaError(PrismaErrorType.UniqueConstraintViolation)
);
const { createDashboard } = await import("./dashboards");
await expect(
createDashboard({
projectId: mockProjectId,
name: "Duplicate",
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
test("throws DatabaseError on other Prisma errors", async () => {
vi.mocked(prisma.dashboard.create).mockRejectedValue(makePrismaError("P9999"));
const { createDashboard } = await import("./dashboards");
await expect(
createDashboard({
projectId: mockProjectId,
name: "Test",
createdBy: mockUserId,
})
).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("updateDashboard", () => {
test("updates a dashboard successfully", async () => {
const updatedDashboard = { ...mockDashboard, name: "Updated Dashboard" };
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.update.mockResolvedValue(updatedDashboard);
const { updateDashboard } = await import("./dashboards");
const result = await updateDashboard(mockDashboardId, mockProjectId, { name: "Updated Dashboard" });
expect(result).toEqual({ dashboard: mockDashboard, updatedDashboard });
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
select: selectDashboard,
});
expect(mockTxDashboard.update).toHaveBeenCalledWith({
where: { id: mockDashboardId },
data: { name: "Updated Dashboard", description: undefined },
select: selectDashboard,
});
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxDashboard.findFirst.mockResolvedValue(null);
const { updateDashboard } = await import("./dashboards");
await expect(
updateDashboard(mockDashboardId, mockProjectId, { name: "Updated" })
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxDashboard.update).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.update.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { updateDashboard } = await import("./dashboards");
await expect(
updateDashboard(mockDashboardId, mockProjectId, { name: "Taken Name" })
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
describe("deleteDashboard", () => {
test("deletes a dashboard successfully", async () => {
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxDashboard.delete.mockResolvedValue(undefined);
const { deleteDashboard } = await import("./dashboards");
const result = await deleteDashboard(mockDashboardId, mockProjectId);
expect(result).toEqual(mockDashboard);
expect(mockTxDashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
select: selectDashboard,
});
expect(mockTxDashboard.delete).toHaveBeenCalledWith({ where: { id: mockDashboardId } });
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxDashboard.findFirst.mockResolvedValue(null);
const { deleteDashboard } = await import("./dashboards");
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxDashboard.delete).not.toHaveBeenCalled();
});
test("throws DatabaseError on Prisma errors", async () => {
mockTxDashboard.findFirst.mockRejectedValue(makePrismaError("P9999"));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { deleteDashboard } = await import("./dashboards");
await expect(deleteDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getDashboard", () => {
test("returns a dashboard with widgets", async () => {
const dashboardWithWidgets = {
...mockDashboard,
widgets: [
{
id: "widget-1",
order: 0,
chart: { id: mockChartId, name: "Chart 1", type: "bar" },
},
],
};
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(dashboardWithWidgets as any);
const { getDashboard } = await import("./dashboards");
const result = await getDashboard(mockDashboardId, mockProjectId);
expect(result).toEqual(dashboardWithWidgets);
expect(prisma.dashboard.findFirst).toHaveBeenCalledWith({
where: { id: mockDashboardId, projectId: mockProjectId },
include: {
widgets: {
orderBy: { order: "asc" },
include: {
chart: {
select: expect.objectContaining({ id: true, name: true, type: true }),
},
},
},
},
});
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
vi.mocked(prisma.dashboard.findFirst).mockResolvedValue(null);
const { getDashboard } = await import("./dashboards");
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.dashboard.findFirst).mockRejectedValue(makePrismaError("P9999"));
const { getDashboard } = await import("./dashboards");
await expect(getDashboard(mockDashboardId, mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("getDashboards", () => {
test("returns all dashboards for a project", async () => {
const dashboards = [
{ ...mockDashboard, _count: { widgets: 3 } },
{ ...mockDashboard, id: "dash-2", name: "Dashboard 2", _count: { widgets: 0 } },
];
vi.mocked(prisma.dashboard.findMany).mockResolvedValue(dashboards as any);
const { getDashboards } = await import("./dashboards");
const result = await getDashboards(mockProjectId);
expect(result).toEqual(dashboards);
expect(prisma.dashboard.findMany).toHaveBeenCalledWith({
where: { projectId: mockProjectId },
orderBy: { createdAt: "desc" },
select: expect.objectContaining({
id: true,
name: true,
_count: { select: { widgets: true } },
}),
});
});
test("returns empty array when no dashboards exist", async () => {
vi.mocked(prisma.dashboard.findMany).mockResolvedValue([]);
const { getDashboards } = await import("./dashboards");
const result = await getDashboards(mockProjectId);
expect(result).toEqual([]);
});
test("throws DatabaseError on Prisma errors", async () => {
vi.mocked(prisma.dashboard.findMany).mockRejectedValue(makePrismaError("P9999"));
const { getDashboards } = await import("./dashboards");
await expect(getDashboards(mockProjectId)).rejects.toMatchObject({
name: "DatabaseError",
});
});
});
describe("addChartToDashboard", () => {
const mockLayout = { x: 0, y: 0, w: 4, h: 3 };
const mockWidget = {
id: "widget-abc-123",
dashboardId: mockDashboardId,
chartId: mockChartId,
title: "My Widget",
layout: mockLayout,
order: 0,
};
test("adds a chart to a dashboard as the first widget", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
mockTxWidget.create.mockResolvedValue(mockWidget);
const { addChartToDashboard } = await import("./dashboards");
const result = await addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
title: "My Widget",
layout: mockLayout,
});
expect(result).toEqual(mockWidget);
expect(mockTxWidget.create).toHaveBeenCalledWith({
data: {
dashboardId: mockDashboardId,
chartId: mockChartId,
title: "My Widget",
layout: mockLayout,
order: 0,
},
});
});
test("appends widget after existing widgets", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: 2 } });
mockTxWidget.create.mockResolvedValue({ ...mockWidget, order: 3 });
const { addChartToDashboard } = await import("./dashboards");
await addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
});
expect(mockTxWidget.create).toHaveBeenCalledWith({
data: expect.objectContaining({ order: 3 }),
});
});
test("throws ResourceNotFoundError when chart does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue(null);
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Chart",
resourceId: mockChartId,
});
expect(mockTxWidget.create).not.toHaveBeenCalled();
});
test("throws ResourceNotFoundError when dashboard does not exist", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(null);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "Dashboard",
resourceId: mockDashboardId,
});
expect(mockTxWidget.create).not.toHaveBeenCalled();
});
test("throws InvalidInputError on unique constraint violation", async () => {
mockTxChart.findFirst.mockResolvedValue({ id: mockChartId });
mockTxDashboard.findFirst.mockResolvedValue(mockDashboard);
mockTxWidget.aggregate.mockResolvedValue({ _max: { order: null } });
mockTxWidget.create.mockRejectedValue(makePrismaError(PrismaErrorType.UniqueConstraintViolation));
vi.mocked(prisma.$transaction).mockImplementation((cb: any) =>
cb({ dashboard: mockTxDashboard, chart: mockTxChart, dashboardWidget: mockTxWidget })
);
const { addChartToDashboard } = await import("./dashboards");
await expect(
addChartToDashboard({
dashboardId: mockDashboardId,
chartId: mockChartId,
projectId: mockProjectId,
layout: mockLayout,
})
).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
});
@@ -0,0 +1,227 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { ZId } from "@formbricks/types/common";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { selectChart } from "@/modules/ee/analysis/charts/lib/charts";
import {
TAddWidgetInput,
TDashboard,
TDashboardCreateInput,
TDashboardUpdateInput,
TDashboardWithCount,
ZAddWidgetInput,
ZDashboardCreateInput,
ZDashboardUpdateInput,
} from "@/modules/ee/analysis/types/analysis";
const selectDashboard = {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
} as const;
export const createDashboard = async (data: TDashboardCreateInput): Promise<TDashboard> => {
validateInputs([data, ZDashboardCreateInput]);
try {
return await prisma.dashboard.create({
data: {
name: data.name,
description: data.description,
projectId: data.projectId,
createdBy: data.createdBy,
},
select: selectDashboard,
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A dashboard with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const updateDashboard = async (
dashboardId: string,
projectId: string,
data: TDashboardUpdateInput
): Promise<{ dashboard: TDashboard; updatedDashboard: TDashboard }> => {
validateInputs([dashboardId, ZId], [projectId, ZId], [data, ZDashboardUpdateInput]);
try {
return await prisma.$transaction(async (tx) => {
const dashboard = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
select: selectDashboard,
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
const updatedDashboard = await tx.dashboard.update({
where: { id: dashboardId },
data: {
name: data.name,
description: data.description,
},
select: selectDashboard,
});
return { dashboard, updatedDashboard };
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("A dashboard with this name already exists");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
export const deleteDashboard = async (dashboardId: string, projectId: string): Promise<TDashboard> => {
validateInputs([dashboardId, ZId], [projectId, ZId]);
try {
return await prisma.$transaction(async (tx) => {
const dashboard = await tx.dashboard.findFirst({
where: { id: dashboardId, projectId },
select: selectDashboard,
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
await tx.dashboard.delete({
where: { id: dashboardId },
});
return dashboard;
});
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getDashboard = async (dashboardId: string, projectId: string) => {
validateInputs([dashboardId, ZId], [projectId, ZId]);
try {
const dashboard = await prisma.dashboard.findFirst({
where: { id: dashboardId, projectId },
include: {
widgets: {
orderBy: { order: "asc" },
include: {
chart: {
select: selectChart,
},
},
},
},
});
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", dashboardId);
}
return dashboard;
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const getDashboards = async (projectId: string): Promise<TDashboardWithCount[]> => {
validateInputs([projectId, ZId]);
try {
return await prisma.dashboard.findMany({
where: { projectId },
orderBy: { createdAt: "desc" },
select: {
...selectDashboard,
_count: { select: { widgets: true } },
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
};
export const addChartToDashboard = async (data: TAddWidgetInput) => {
validateInputs([data, ZAddWidgetInput]);
try {
return await prisma.$transaction(
async (tx) => {
const [chart, dashboard] = await Promise.all([
tx.chart.findFirst({ where: { id: data.chartId, projectId: data.projectId } }),
tx.dashboard.findFirst({ where: { id: data.dashboardId, projectId: data.projectId } }),
]);
if (!chart) {
throw new ResourceNotFoundError("Chart", data.chartId);
}
if (!dashboard) {
throw new ResourceNotFoundError("Dashboard", data.dashboardId);
}
const maxOrder = await tx.dashboardWidget.aggregate({
where: { dashboardId: data.dashboardId },
_max: { order: true },
});
return tx.dashboardWidget.create({
data: {
dashboardId: data.dashboardId,
chartId: data.chartId,
title: data.title,
layout: data.layout,
order: (maxOrder._max.order ?? -1) + 1,
},
});
},
{ isolationLevel: "Serializable" }
);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
throw error;
}
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
throw new InvalidInputError("This chart is already on the dashboard");
}
throw new DatabaseError(error.message);
}
throw error;
}
};
@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.mock("server-only", () => ({}));
const mockGetEnvironment = vi.fn();
const mockGetOrganizationIdFromProjectId = vi.fn();
const mockCheckAuthorizationUpdated = vi.fn();
vi.mock("@/lib/environment/service", () => ({
getEnvironment: (...args: any[]) => mockGetEnvironment(...args),
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromProjectId: (...args: any[]) => mockGetOrganizationIdFromProjectId(...args),
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: (...args: any[]) => mockCheckAuthorizationUpdated(...args),
}));
const mockUserId = "user-abc-123";
const mockEnvironmentId = "env-abc-123";
const mockProjectId = "project-abc-123";
const mockOrganizationId = "org-abc-123";
describe("checkProjectAccess", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("returns organizationId and projectId on successful access check", async () => {
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
mockCheckAuthorizationUpdated.mockResolvedValue(undefined);
const { checkProjectAccess } = await import("./access");
const result = await checkProjectAccess(mockUserId, mockEnvironmentId, "readWrite");
expect(result).toEqual({ organizationId: mockOrganizationId, projectId: mockProjectId });
expect(mockGetEnvironment).toHaveBeenCalledWith(mockEnvironmentId);
expect(mockGetOrganizationIdFromProjectId).toHaveBeenCalledWith(mockProjectId);
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
userId: mockUserId,
organizationId: mockOrganizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission: "readWrite", projectId: mockProjectId },
],
});
});
test("throws ResourceNotFoundError when environment is not found", async () => {
mockGetEnvironment.mockResolvedValue(null);
const { checkProjectAccess } = await import("./access");
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "read")).rejects.toMatchObject({
name: "ResourceNotFoundError",
resourceType: "environment",
resourceId: mockEnvironmentId,
});
expect(mockGetOrganizationIdFromProjectId).not.toHaveBeenCalled();
expect(mockCheckAuthorizationUpdated).not.toHaveBeenCalled();
});
test("propagates authorization errors from checkAuthorizationUpdated", async () => {
mockGetEnvironment.mockResolvedValue({ projectId: mockProjectId });
mockGetOrganizationIdFromProjectId.mockResolvedValue(mockOrganizationId);
mockCheckAuthorizationUpdated.mockRejectedValue(new Error("Unauthorized"));
const { checkProjectAccess } = await import("./access");
await expect(checkProjectAccess(mockUserId, mockEnvironmentId, "manage")).rejects.toThrow("Unauthorized");
});
});
@@ -0,0 +1,31 @@
import "server-only";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { getEnvironment } from "@/lib/environment/service";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
import { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
export const checkProjectAccess = async (
userId: string,
environmentId: string,
minPermission: TTeamPermission
) => {
const environment = await getEnvironment(environmentId);
if (!environment) {
throw new ResourceNotFoundError("environment", environmentId);
}
const projectId = environment.projectId;
const organizationId = await getOrganizationIdFromProjectId(projectId);
await checkAuthorizationUpdated({
userId,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{ type: "projectTeam", minPermission, projectId },
],
});
return { organizationId, projectId };
};
@@ -0,0 +1,83 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZChartConfig, ZChartQuery, ZWidgetLayout } from "@formbricks/types/dashboard";
export const ZChartType = z.enum(["area", "bar", "line", "pie", "big_number"]);
export type TChartType = z.infer<typeof ZChartType>;
// ── Chart input schemas ─────────────────────────────────────────────────────
export const ZChartCreateInput = z.object({
projectId: ZId,
name: z.string().min(1),
type: ZChartType,
query: ZChartQuery,
config: ZChartConfig,
createdBy: ZId,
});
export type TChartCreateInput = z.infer<typeof ZChartCreateInput>;
export const ZChartUpdateInput = z.object({
name: z.string().min(1).optional(),
type: ZChartType.optional(),
query: ZChartQuery.optional(),
config: ZChartConfig.optional(),
});
export type TChartUpdateInput = z.infer<typeof ZChartUpdateInput>;
// ── Chart output type (matches selectChart) ─────────────────────────────────
export type TChart = {
id: string;
name: string;
type: string;
query: unknown;
config: unknown;
createdAt: Date;
updatedAt: Date;
};
export type TChartWithWidgets = TChart & {
widgets: { dashboardId: string }[];
};
// ── Dashboard input schemas ─────────────────────────────────────────────────
export const ZDashboardCreateInput = z.object({
projectId: ZId,
name: z.string().min(1),
description: z.string().optional(),
createdBy: ZId,
});
export type TDashboardCreateInput = z.infer<typeof ZDashboardCreateInput>;
export const ZDashboardUpdateInput = z.object({
name: z.string().min(1).optional(),
description: z.string().optional().nullable(),
});
export type TDashboardUpdateInput = z.infer<typeof ZDashboardUpdateInput>;
// ── Dashboard output type (matches selectDashboard) ─────────────────────────
export type TDashboard = {
id: string;
name: string;
description: string | null;
createdAt: Date;
updatedAt: Date;
};
export type TDashboardWithCount = TDashboard & {
_count: { widgets: number };
};
// ── Widget input schema ─────────────────────────────────────────────────────
export const ZAddWidgetInput = z.object({
dashboardId: ZId,
chartId: ZId,
projectId: ZId,
title: z.string().optional(),
layout: ZWidgetLayout,
});
export type TAddWidgetInput = z.infer<typeof ZAddWidgetInput>;
@@ -229,4 +229,49 @@ describe("withAuditLogging", () => {
// Reset for other tests; clearAllMockHandles will also do this in the next beforeEach
if (mutableConstants) mutableConstants.AUDIT_LOG_ENABLED = true;
});
test("resolves targetId for chart target type", async () => {
const chartCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, chartId: "chart-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "chart", handlerImpl);
await wrapped({ ctx: chartCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("chart");
expect(callArgs.target.id).toBe("chart-1");
});
test("resolves targetId for dashboard target type", async () => {
const dashCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardId: "dash-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "dashboard", handlerImpl);
await wrapped({ ctx: dashCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("dashboard");
expect(callArgs.target.id).toBe("dash-1");
});
test("resolves targetId for dashboardWidget target type", async () => {
const widgetCtx = {
...mockCtxBase,
auditLoggingCtx: { ...mockCtxBase.auditLoggingCtx, dashboardWidgetId: "widget-1" },
};
const handlerImpl = vi.fn().mockResolvedValue("ok");
const wrapped = OriginalHandler.withAuditLogging("created", "dashboardWidget", handlerImpl);
await wrapped({ ctx: widgetCtx as any, parsedInput: mockParsedInput });
await new Promise(setImmediate);
expect(serviceLogAuditEventMockHandle).toHaveBeenCalled();
const callArgs = serviceLogAuditEventMockHandle.mock.calls[0][0];
expect(callArgs.target.type).toBe("dashboardWidget");
expect(callArgs.target.id).toBe("widget-1");
});
});
@@ -292,6 +292,15 @@ export const withAuditLogging = <TParsedInput = Record<string, unknown>, TResult
case "quota":
targetId = auditLoggingCtx.quotaId;
break;
case "chart":
targetId = auditLoggingCtx.chartId;
break;
case "dashboard":
targetId = auditLoggingCtx.dashboardId;
break;
case "dashboardWidget":
targetId = auditLoggingCtx.dashboardWidgetId;
break;
default:
targetId = UNKNOWN_DATA;
break;
@@ -25,6 +25,9 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"chart",
"dashboard",
"dashboardWidget",
]);
export const ZAuditAction = z.enum([
"created",
+230 -1
View File
@@ -93,6 +93,9 @@ async function deleteData(): Promise<void> {
"segment",
"webhook",
"integration",
"dashboardWidget",
"chart",
"dashboard",
"projectTeam",
"teamUser",
"team",
@@ -570,8 +573,234 @@ async function main(): Promise<void> {
await generateResponses(SEED_IDS.SURVEY_CSAT, 50);
await generateResponses(SEED_IDS.SURVEY_COMPLETED, 50);
// Charts & Dashboards
logger.info("Seeding charts and dashboards...");
const chartResponsesOverTime = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_RESPONSES_OVER_TIME },
update: {},
create: {
id: SEED_IDS.CHART_RESPONSES_OVER_TIME,
name: "Responses Over Time",
type: "line",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
timeDimensions: [{ dimension: "FeedbackRecords.createdAt", granularity: "week" }],
},
config: {
xAxisLabel: "Week",
yAxisLabel: "Responses",
showGrid: true,
showLegend: false,
},
},
});
const chartSatisfactionDist = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_SATISFACTION_DIST },
update: {},
create: {
id: SEED_IDS.CHART_SATISFACTION_DIST,
name: "Satisfaction Distribution",
type: "pie",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.rating"],
},
config: {
showLegend: true,
legendPosition: "right",
colors: ["#ef4444", "#f97316", "#eab308", "#22c55e", "#10b981"],
},
},
});
const chartNpsScore = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_NPS_SCORE },
update: {},
create: {
id: SEED_IDS.CHART_NPS_SCORE,
name: "NPS Score",
type: "big_number",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.npsScore"],
},
config: {
prefix: "",
suffix: "",
numberFormat: "0",
},
},
});
const chartCompletionRate = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_COMPLETION_RATE },
update: {},
create: {
id: SEED_IDS.CHART_COMPLETION_RATE,
name: "Survey Completion Rate",
type: "bar",
projectId: project.id,
createdBy: SEED_IDS.USER_MANAGER,
query: {
measures: ["FeedbackRecords.completionRate"],
dimensions: ["FeedbackRecords.surveyName"],
},
config: {
xAxisLabel: "Survey",
yAxisLabel: "Completion %",
showValues: true,
showGrid: true,
colors: ["#6366f1"],
},
},
});
const chartTopChannels = await prisma.chart.upsert({
where: { id: SEED_IDS.CHART_TOP_CHANNELS },
update: {},
create: {
id: SEED_IDS.CHART_TOP_CHANNELS,
name: "Responses by Channel",
type: "area",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
query: {
measures: ["FeedbackRecords.count"],
dimensions: ["FeedbackRecords.channel"],
timeDimensions: [{ dimension: "FeedbackRecords.createdAt", granularity: "month" }],
},
config: {
stacked: true,
showLegend: true,
legendPosition: "bottom",
showGrid: true,
},
},
});
// Dashboard: Overview
const dashboardOverview = await prisma.dashboard.upsert({
where: { id: SEED_IDS.DASHBOARD_OVERVIEW },
update: {},
create: {
id: SEED_IDS.DASHBOARD_OVERVIEW,
name: "Overview",
description: "High-level metrics across all surveys",
projectId: project.id,
createdBy: SEED_IDS.USER_ADMIN,
},
});
// Dashboard: Survey Performance
const dashboardSurveyPerf = await prisma.dashboard.upsert({
where: { id: SEED_IDS.DASHBOARD_SURVEY_PERF },
update: {},
create: {
id: SEED_IDS.DASHBOARD_SURVEY_PERF,
name: "Survey Performance",
description: "Detailed survey completion and response metrics",
projectId: project.id,
createdBy: SEED_IDS.USER_MANAGER,
},
});
// Widgets for Overview dashboard
await prisma.dashboardWidget.upsert({
where: { id: SEED_IDS.WIDGET_OVERVIEW_NPS },
update: {},
create: {
id: SEED_IDS.WIDGET_OVERVIEW_NPS,
dashboardId: dashboardOverview.id,
chartId: chartNpsScore.id,
title: "Current NPS",
layout: { x: 0, y: 0, w: 2, h: 2 },
order: 0,
},
});
await prisma.dashboardWidget.upsert({
where: { id: SEED_IDS.WIDGET_OVERVIEW_RESPONSES },
update: {},
create: {
id: SEED_IDS.WIDGET_OVERVIEW_RESPONSES,
dashboardId: dashboardOverview.id,
chartId: chartResponsesOverTime.id,
title: "Weekly Responses",
layout: { x: 2, y: 0, w: 4, h: 3 },
order: 1,
},
});
await prisma.dashboardWidget.upsert({
where: { id: SEED_IDS.WIDGET_OVERVIEW_SATISFACTION },
update: {},
create: {
id: SEED_IDS.WIDGET_OVERVIEW_SATISFACTION,
dashboardId: dashboardOverview.id,
chartId: chartSatisfactionDist.id,
title: "Satisfaction Breakdown",
layout: { x: 0, y: 3, w: 3, h: 3 },
order: 2,
},
});
await prisma.dashboardWidget.upsert({
where: { id: SEED_IDS.WIDGET_OVERVIEW_CHANNELS },
update: {},
create: {
id: SEED_IDS.WIDGET_OVERVIEW_CHANNELS,
dashboardId: dashboardOverview.id,
chartId: chartTopChannels.id,
title: "Channel Trends",
layout: { x: 3, y: 3, w: 3, h: 3 },
order: 3,
},
});
// Widgets for Survey Performance dashboard
await prisma.dashboardWidget.upsert({
where: { id: SEED_IDS.WIDGET_SURVPERF_COMPLETION },
update: {},
create: {
id: SEED_IDS.WIDGET_SURVPERF_COMPLETION,
dashboardId: dashboardSurveyPerf.id,
chartId: chartCompletionRate.id,
title: "Completion by Survey",
layout: { x: 0, y: 0, w: 6, h: 3 },
order: 0,
},
});
await prisma.dashboardWidget.upsert({
where: { id: SEED_IDS.WIDGET_SURVPERF_RESPONSES },
update: {},
create: {
id: SEED_IDS.WIDGET_SURVPERF_RESPONSES,
dashboardId: dashboardSurveyPerf.id,
chartId: chartResponsesOverTime.id,
title: "Response Volume",
layout: { x: 0, y: 3, w: 4, h: 3 },
order: 1,
},
});
await prisma.dashboardWidget.upsert({
where: { id: SEED_IDS.WIDGET_SURVPERF_NPS },
update: {},
create: {
id: SEED_IDS.WIDGET_SURVPERF_NPS,
dashboardId: dashboardSurveyPerf.id,
chartId: chartNpsScore.id,
title: "NPS Tracker",
layout: { x: 4, y: 3, w: 2, h: 2 },
order: 2,
},
});
logger.info(`\n${"=".repeat(50)}`);
logger.info("🚀 SEEDING COMPLETED SUCCESSFULLY");
logger.info("SEEDING COMPLETED SUCCESSFULLY");
logger.info("=".repeat(50));
logger.info("\nLog in with the following credentials:");
logger.info(`\n Admin (Owner):`);
+14
View File
@@ -10,6 +10,20 @@ export const SEED_IDS = {
SURVEY_CSAT: "clseedsurveycsat000000",
SURVEY_DRAFT: "clseedsurveydraft00000",
SURVEY_COMPLETED: "clseedsurveycomplete00",
CHART_RESPONSES_OVER_TIME: "clseedchartresptime00",
CHART_SATISFACTION_DIST: "clseedchartsatdist000",
CHART_NPS_SCORE: "clseedchartnpsscore00",
CHART_COMPLETION_RATE: "clseedchartcomplete00",
CHART_TOP_CHANNELS: "clseedcharttopchann00",
DASHBOARD_OVERVIEW: "clseeddashovervieww00",
DASHBOARD_SURVEY_PERF: "clseeddashsurvperf000",
WIDGET_OVERVIEW_NPS: "clseedwidgetovwnps000",
WIDGET_OVERVIEW_RESPONSES: "clseedwidgetovwresp00",
WIDGET_OVERVIEW_SATISFACTION: "clseedwidgetovwsat000",
WIDGET_OVERVIEW_CHANNELS: "clseedwidgetovwchan00",
WIDGET_SURVPERF_COMPLETION: "clseedwidgetspcomp000",
WIDGET_SURVPERF_RESPONSES: "clseedwidgetspresp000",
WIDGET_SURVPERF_NPS: "clseedwidgetspnps0000",
} as const;
export const SEED_CREDENTIALS = {