mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-21 13:08:40 -05:00
feat: (dashboards) crud charts/dashboard server actions (#7307)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
@@ -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 || {};
|
||||
|
||||
+15
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
+1
@@ -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",
|
||||
|
||||
@@ -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):`);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user