mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-01 19:59:35 -05:00
feat: adding CRUD operations for charts
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user