feat: adding CRUD operations for charts

This commit is contained in:
TheodorTomas
2026-02-19 17:51:13 +07:00
parent f49f40610b
commit d32437b4a6
13 changed files with 855 additions and 1 deletions
@@ -0,0 +1,93 @@
import { Chart, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { TChartUpdateInput } from "@/modules/api/v2/management/charts/[chartId]/types/charts";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const getChart = async (chartId: string): Promise<Result<Chart, ApiErrorResponseV2>> => {
try {
const chart = await prisma.chart.findUnique({
where: { id: chartId },
});
if (!chart) {
return err({
type: "not_found",
details: [{ field: "chart", issue: "not found" }],
});
}
return ok(chart);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "chart", issue: (error as Error).message }],
});
}
};
export const updateChart = async (
chartId: string,
chartInput: TChartUpdateInput
): Promise<Result<Chart, ApiErrorResponseV2>> => {
try {
const updatedChart = await prisma.chart.update({
where: { id: chartId },
data: chartInput,
});
return ok(updatedChart);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "chart", issue: "not found" }],
});
}
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [{ field: "name", issue: "A chart with this name already exists in the project" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "chart", issue: (error as Error).message }],
});
}
};
export const deleteChart = async (chartId: string): Promise<Result<Chart, ApiErrorResponseV2>> => {
try {
const deletedChart = await prisma.chart.delete({
where: { id: chartId },
});
return ok(deletedChart);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "chart", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "chart", issue: (error as Error).message }],
});
}
};
@@ -0,0 +1,120 @@
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TChartUpdateInput } from "@/modules/api/v2/management/charts/[chartId]/types/charts";
import { deleteChart, getChart, updateChart } from "../chart";
import { mockedChart, prismaNotFoundError, prismaUniqueConstraintError } from "./mocks/chart.mock";
vi.mock("@formbricks/database", () => ({
prisma: {
chart: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
describe("getChart", () => {
test("returns ok if chart is found", async () => {
vi.mocked(prisma.chart.findUnique).mockResolvedValueOnce(mockedChart);
const result = await getChart("chart123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockedChart);
}
});
test("returns err if chart not found", async () => {
vi.mocked(prisma.chart.findUnique).mockResolvedValueOnce(null);
const result = await getChart("nonexistent");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("not_found");
}
});
test("returns err on Prisma error", async () => {
vi.mocked(prisma.chart.findUnique).mockRejectedValueOnce(new Error("DB error"));
const result = await getChart("error");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateChart", () => {
const updateInput: TChartUpdateInput = { name: "Updated Chart" };
test("returns ok on successful update", async () => {
const updatedChart = { ...mockedChart, name: "Updated Chart" };
vi.mocked(prisma.chart.update).mockResolvedValueOnce(updatedChart);
const result = await updateChart("chart123", updateInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated Chart");
}
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.chart.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateChart("nonexistent", updateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("not_found");
}
});
test("returns conflict on duplicate name", async () => {
vi.mocked(prisma.chart.update).mockRejectedValueOnce(prismaUniqueConstraintError);
const result = await updateChart("chart123", updateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("conflict");
}
});
test("returns internal_server_error on other errors", async () => {
vi.mocked(prisma.chart.update).mockRejectedValueOnce(new Error("Unknown error"));
const result = await updateChart("chart123", updateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("internal_server_error");
}
});
});
describe("deleteChart", () => {
test("returns ok on successful delete", async () => {
vi.mocked(prisma.chart.delete).mockResolvedValueOnce(mockedChart);
const result = await deleteChart("chart123");
expect(result.ok).toBe(true);
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.chart.delete).mockRejectedValueOnce(prismaNotFoundError);
const result = await deleteChart("nonexistent");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("not_found");
}
});
test("returns internal_server_error on other errors", async () => {
vi.mocked(prisma.chart.delete).mockRejectedValueOnce(new Error("Delete error"));
const result = await deleteChart("error");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("internal_server_error");
}
});
});
@@ -0,0 +1,27 @@
import { ChartType, Prisma } from "@prisma/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const mockedChart = {
id: "chart123",
name: "Test Chart",
type: "bar" as ChartType,
projectId: "project1",
query: { measures: ["Orders.count"] },
config: {},
createdBy: null,
createdAt: new Date("2026-01-28T12:00:00.000Z"),
updatedAt: new Date("2026-01-28T12:00:00.000Z"),
};
export const prismaNotFoundError = new Prisma.PrismaClientKnownRequestError("Record does not exist", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "PrismaClient 4.0.0",
});
export const prismaUniqueConstraintError = new Prisma.PrismaClientKnownRequestError(
"Unique constraint failed",
{
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "PrismaClient 4.0.0",
}
);
@@ -0,0 +1,166 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import {
deleteChart,
getChart,
updateChart,
} from "@/modules/api/v2/management/charts/[chartId]/lib/chart";
import {
ZChartIdSchema,
ZChartUpdateInput,
} from "@/modules/api/v2/management/charts/[chartId]/types/charts";
import { hasProjectPermission } from "@/modules/api/v2/management/charts/lib/utils";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const GET = async (request: NextRequest, props: { params: Promise<{ chartId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ chartId: ZChartIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const chart = await getChart(params.chartId);
if (!chart.ok) {
return handleApiError(request, chart.error as ApiErrorResponseV2);
}
if (!hasProjectPermission(authentication.environmentPermissions, chart.data.projectId, "GET")) {
return handleApiError(request, {
type: "forbidden",
details: [{ field: "chart", issue: "does not have permission to access this chart" }],
});
}
return responses.successResponse(chart);
},
});
export const PUT = async (request: NextRequest, props: { params: Promise<{ chartId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ chartId: ZChartIdSchema }),
body: ZChartUpdateInput,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params, body } = parsedInput;
if (auditLog) {
auditLog.targetId = params?.chartId;
}
if (!body || !params) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
},
auditLog
);
}
const chart = await getChart(params.chartId);
if (!chart.ok) {
return handleApiError(request, chart.error as ApiErrorResponseV2, auditLog);
}
if (!hasProjectPermission(authentication.environmentPermissions, chart.data.projectId, "PUT")) {
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "chart", issue: "does not have permission to update this chart" }],
},
auditLog
);
}
const updatedChart = await updateChart(params.chartId, body);
if (!updatedChart.ok) {
return handleApiError(request, updatedChart.error as ApiErrorResponseV2, auditLog); // NOSONAR
}
if (auditLog) {
auditLog.oldObject = chart.data;
auditLog.newObject = updatedChart.data;
}
return responses.successResponse(updatedChart);
},
action: "updated",
targetType: "chart",
});
export const DELETE = async (request: NextRequest, props: { params: Promise<{ chartId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ chartId: ZChartIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params } = parsedInput;
if (auditLog) {
auditLog.targetId = params?.chartId;
}
if (!params) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
},
auditLog
);
}
const chart = await getChart(params.chartId);
if (!chart.ok) {
return handleApiError(request, chart.error as ApiErrorResponseV2, auditLog);
}
if (!hasProjectPermission(authentication.environmentPermissions, chart.data.projectId, "DELETE")) {
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "chart", issue: "does not have permission to delete this chart" }],
},
auditLog
);
}
const deletedChart = await deleteChart(params.chartId);
if (!deletedChart.ok) {
return handleApiError(request, deletedChart.error as ApiErrorResponseV2, auditLog); // NOSONAR
}
if (auditLog) {
auditLog.oldObject = chart.data;
}
return responses.successResponse(deletedChart);
},
action: "deleted",
targetType: "chart",
});
@@ -0,0 +1,32 @@
import { ChartType } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZChartConfig, ZChartQuery } from "@formbricks/types/dashboard";
extendZodWithOpenApi(z);
export const ZChartIdSchema = z
.string()
.cuid2()
.openapi({
ref: "chartId",
description: "The ID of the chart",
param: {
name: "id",
in: "path",
},
});
export const ZChartUpdateInput = z
.object({
name: z.string().trim().min(1).max(255).optional(),
type: z.nativeEnum(ChartType).optional(),
query: ZChartQuery.optional(),
config: ZChartConfig.optional(),
})
.openapi({
ref: "chartUpdate",
description: "The fields to update on a chart.",
});
export type TChartUpdateInput = z.infer<typeof ZChartUpdateInput>;
@@ -0,0 +1,81 @@
import { Chart, Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { getChartsQuery } from "@/modules/api/v2/management/charts/lib/utils";
import { TChartInput, TGetChartsFilter } from "@/modules/api/v2/management/charts/types/charts";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
export const getCharts = async (
projectIds: string[],
params: TGetChartsFilter
): Promise<Result<ApiResponseWithMeta<Chart[]>, ApiErrorResponseV2>> => {
try {
const query = getChartsQuery(projectIds, params);
const [charts, count] = await prisma.$transaction([
prisma.chart.findMany({
...query,
}),
prisma.chart.count({
where: query.where,
}),
]);
return ok({
data: charts,
meta: {
total: count,
limit: params?.limit,
offset: params?.skip,
},
});
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "charts", issue: (error as Error).message }],
});
}
};
export const createChart = async (
chartInput: TChartInput,
createdBy?: string
): Promise<Result<Chart, ApiErrorResponseV2>> => {
try {
const chart = await prisma.chart.create({
data: {
name: chartInput.name,
type: chartInput.type,
projectId: chartInput.projectId,
query: chartInput.query,
config: chartInput.config ?? {},
createdBy: createdBy ?? null,
},
});
return ok(chart);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [{ field: "name", issue: "A chart with this name already exists in the project" }],
});
}
if (error.code === PrismaErrorType.RelatedRecordDoesNotExist) {
return err({
type: "not_found",
details: [{ field: "projectId", issue: "Project not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "chart", issue: (error as Error).message }],
});
}
};
@@ -0,0 +1,115 @@
import { ChartType } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TChartInput, TGetChartsFilter } from "@/modules/api/v2/management/charts/types/charts";
import { createChart, getCharts } from "../chart";
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
chart: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
},
}));
describe("getCharts", () => {
const projectIds = ["project1"];
const params = {
limit: 10,
skip: 0,
};
const fakeCharts = [
{ id: "c1", projectId: "project1", name: "Chart One", type: "bar" },
{ id: "c2", projectId: "project1", name: "Chart Two", type: "line" },
];
const count = fakeCharts.length;
test("returns ok response with charts and meta", async () => {
vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeCharts, count]);
const result = await getCharts(projectIds, params as TGetChartsFilter);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(fakeCharts);
expect(result.data.meta).toEqual({
total: count,
limit: params.limit,
offset: params.skip,
});
}
});
test("returns error when prisma.$transaction throws", async () => {
vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error"));
const result = await getCharts(projectIds, params as TGetChartsFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toEqual("internal_server_error");
}
});
});
describe("createChart", () => {
const inputChart: TChartInput = {
projectId: "project1",
name: "New Chart",
type: "bar" as ChartType,
query: { measures: ["Orders.count"] },
config: {},
};
const createdChart = {
id: "c100",
projectId: inputChart.projectId,
name: inputChart.name,
type: inputChart.type,
query: inputChart.query,
config: inputChart.config,
createdBy: null,
createdAt: new Date(),
updatedAt: new Date(),
};
test("creates a chart", async () => {
vi.mocked(prisma.chart.create).mockResolvedValueOnce(createdChart);
const result = await createChart(inputChart);
expect(prisma.chart.create).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(createdChart);
}
});
test("returns conflict error on duplicate name", async () => {
const { prismaUniqueConstraintError } = await import(
"@/modules/api/v2/management/charts/[chartId]/lib/tests/mocks/chart.mock"
);
vi.mocked(prisma.chart.create).mockRejectedValueOnce(prismaUniqueConstraintError);
const result = await createChart(inputChart);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("conflict");
}
});
test("returns error when creation fails", async () => {
vi.mocked(prisma.chart.create).mockRejectedValueOnce(new Error("Creation failed"));
const result = await createChart(inputChart);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
});
@@ -0,0 +1,51 @@
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { hasProjectPermission } from "../utils";
const makePermission = (
projectId: string,
environmentId: string,
permission: ApiKeyPermission
): TAPIKeyEnvironmentPermission => ({
projectId,
environmentId,
environmentType: "production" as EnvironmentType,
projectName: "Test Project",
permission,
});
describe("hasProjectPermission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
makePermission("project1", "env1", "manage"),
makePermission("project2", "env2", "read"),
];
test("returns true for GET on project with manage permission", () => {
expect(hasProjectPermission(permissions, "project1", "GET")).toBe(true);
});
test("returns true for DELETE on project with manage permission", () => {
expect(hasProjectPermission(permissions, "project1", "DELETE")).toBe(true);
});
test("returns true for GET on project with read permission", () => {
expect(hasProjectPermission(permissions, "project2", "GET")).toBe(true);
});
test("returns false for POST on project with read permission", () => {
expect(hasProjectPermission(permissions, "project2", "POST")).toBe(false);
});
test("returns false for DELETE on project with read permission", () => {
expect(hasProjectPermission(permissions, "project2", "DELETE")).toBe(false);
});
test("returns false for unknown project", () => {
expect(hasProjectPermission(permissions, "unknown-project", "GET")).toBe(false);
});
test("returns false for empty permissions array", () => {
expect(hasProjectPermission([], "project1", "GET")).toBe(false);
});
});
@@ -0,0 +1,43 @@
import { Prisma } from "@prisma/client";
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetChartsFilter } from "@/modules/api/v2/management/charts/types/charts";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
export const hasProjectPermission = (
permissions: TAPIKeyEnvironmentPermission[],
projectId: string,
method: "GET" | "POST" | "PUT" | "DELETE"
): boolean => {
const projectPerms = permissions.filter((p) => p.projectId === projectId);
return projectPerms.some((p) => hasPermission([p], p.environmentId, method));
};
export const getChartsQuery = (projectIds: string[], params?: TGetChartsFilter) => {
let query: Prisma.ChartFindManyArgs = {
where: {
projectId: { in: projectIds },
},
orderBy: { createdAt: "desc" },
};
if (!params) return query;
if (params.projectId) {
query = {
...query,
where: {
...query.where,
projectId: params.projectId,
},
};
}
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.ChartFindManyArgs>(query, baseFilter);
}
return query;
};
@@ -0,0 +1,94 @@
import { NextRequest } from "next/server";
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { createChart, getCharts } from "@/modules/api/v2/management/charts/lib/chart";
import { hasProjectPermission } from "@/modules/api/v2/management/charts/lib/utils";
import { ZChartInput, ZGetChartsFilter } from "@/modules/api/v2/management/charts/types/charts";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetChartsFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
if (!query) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "query", issue: "missing" }],
});
}
const projectIds = [
...new Set(authentication.environmentPermissions.map((permission) => permission.projectId)),
];
if (query.projectId && !projectIds.includes(query.projectId)) {
return handleApiError(request, {
type: "forbidden",
details: [{ field: "projectId", issue: "does not have permission to access this project" }],
});
}
const filteredProjectIds = query.projectId ? [query.projectId] : projectIds;
const res = await getCharts(filteredProjectIds, query);
if (res.ok) {
return responses.successResponse(res.data);
}
return handleApiError(request, res.error);
},
});
export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZChartInput,
},
handler: async ({ authentication, parsedInput, auditLog }) => {
const { body } = parsedInput;
if (!body) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "body", issue: "missing" }],
},
auditLog
);
}
if (!hasProjectPermission(authentication.environmentPermissions, body.projectId, "POST")) {
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "projectId", issue: "does not have permission to create chart" }],
},
auditLog
);
}
const createChartResult = await createChart(body);
if (!createChartResult.ok) {
return handleApiError(request, createChartResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createChartResult.data.id;
auditLog.newObject = createChartResult.data;
}
return responses.createdResponse(createChartResult);
},
action: "created",
targetType: "chart",
});
@@ -0,0 +1,30 @@
import { ChartType } from "@prisma/client";
import { z } from "zod";
import { ZChartConfig, ZChartQuery } from "@formbricks/types/dashboard";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
export const ZChartInput = z.object({
projectId: z.string().cuid2(),
name: z.string().trim().min(1).max(255),
type: z.nativeEnum(ChartType),
query: ZChartQuery,
config: ZChartConfig.optional().default({}),
});
export type TChartInput = z.infer<typeof ZChartInput>;
export const ZGetChartsFilter = ZGetFilter.extend({
projectId: z.string().cuid2().optional(),
}).refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export type TGetChartsFilter = z.infer<typeof ZGetChartsFilter>;
@@ -12,7 +12,8 @@ type HasFindMany =
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
| Prisma.ContactAttributeKeyFindManyArgs
| Prisma.ChartFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate, filterDateField = "createdAt" } = params || {};
@@ -25,6 +25,7 @@ export const ZAuditTarget = z.enum([
"integration",
"file",
"quota",
"chart",
]);
export const ZAuditAction = z.enum([
"created",