fix: changing dashboard api routes to be server actions

This commit is contained in:
TheodorTomas
2026-02-19 20:05:01 +07:00
parent 6080fb9c63
commit 5b9372fb69
10 changed files with 0 additions and 811 deletions

View File

@@ -1,97 +0,0 @@
import { Dashboard, 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 { TDashboardUpdateInput } from "@/modules/api/v2/management/dashboards/[dashboardId]/types/dashboards";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const getDashboard = async (dashboardId: string): Promise<Result<Dashboard, ApiErrorResponseV2>> => {
try {
const dashboard = await prisma.dashboard.findUnique({
where: { id: dashboardId },
});
if (!dashboard) {
return err({
type: "not_found",
details: [{ field: "dashboard", issue: "not found" }],
});
}
return ok(dashboard);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "dashboard", issue: (error as Error).message }],
});
}
};
export const updateDashboard = async (
dashboardId: string,
dashboardInput: TDashboardUpdateInput
): Promise<Result<Dashboard, ApiErrorResponseV2>> => {
try {
const updatedDashboard = await prisma.dashboard.update({
where: { id: dashboardId },
data: dashboardInput,
});
return ok(updatedDashboard);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "dashboard", issue: "not found" }],
});
}
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [
{ field: "name", issue: "A dashboard with this name already exists in the project" },
],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "dashboard", issue: (error as Error).message }],
});
}
};
export const deleteDashboard = async (
dashboardId: string
): Promise<Result<Dashboard, ApiErrorResponseV2>> => {
try {
const deletedDashboard = await prisma.dashboard.delete({
where: { id: dashboardId },
});
return ok(deletedDashboard);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "dashboard", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "dashboard", issue: (error as Error).message }],
});
}
};

View File

@@ -1,124 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TDashboardUpdateInput } from "@/modules/api/v2/management/dashboards/[dashboardId]/types/dashboards";
import { deleteDashboard, getDashboard, updateDashboard } from "../dashboard";
import {
mockedDashboard,
prismaNotFoundError,
prismaUniqueConstraintError,
} from "./mocks/dashboard.mock";
vi.mock("@formbricks/database", () => ({
prisma: {
dashboard: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
describe("getDashboard", () => {
test("returns ok if dashboard is found", async () => {
vi.mocked(prisma.dashboard.findUnique).mockResolvedValueOnce(mockedDashboard);
const result = await getDashboard("dashboard123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockedDashboard);
}
});
test("returns err if dashboard not found", async () => {
vi.mocked(prisma.dashboard.findUnique).mockResolvedValueOnce(null);
const result = await getDashboard("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.dashboard.findUnique).mockRejectedValueOnce(new Error("DB error"));
const result = await getDashboard("error");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateDashboard", () => {
const updateInput: TDashboardUpdateInput = { name: "Updated Dashboard" };
test("returns ok on successful update", async () => {
const updatedDashboard = { ...mockedDashboard, name: "Updated Dashboard" };
vi.mocked(prisma.dashboard.update).mockResolvedValueOnce(updatedDashboard);
const result = await updateDashboard("dashboard123", updateInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.name).toBe("Updated Dashboard");
}
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.dashboard.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateDashboard("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.dashboard.update).mockRejectedValueOnce(prismaUniqueConstraintError);
const result = await updateDashboard("dashboard123", 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.dashboard.update).mockRejectedValueOnce(new Error("Unknown error"));
const result = await updateDashboard("dashboard123", updateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("internal_server_error");
}
});
});
describe("deleteDashboard", () => {
test("returns ok on successful delete", async () => {
vi.mocked(prisma.dashboard.delete).mockResolvedValueOnce(mockedDashboard);
const result = await deleteDashboard("dashboard123");
expect(result.ok).toBe(true);
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.dashboard.delete).mockRejectedValueOnce(prismaNotFoundError);
const result = await deleteDashboard("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.dashboard.delete).mockRejectedValueOnce(new Error("Delete error"));
const result = await deleteDashboard("error");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("internal_server_error");
}
});
});

View File

@@ -1,25 +0,0 @@
import { Prisma } from "@prisma/client";
import { PrismaErrorType } from "@formbricks/database/types/error";
export const mockedDashboard = {
id: "dashboard123",
name: "Test Dashboard",
description: "A test dashboard",
projectId: "project1",
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",
}
);

View File

@@ -1,185 +0,0 @@
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 { hasProjectPermission } from "@/modules/api/v2/management/charts/lib/utils";
import {
deleteDashboard,
getDashboard,
updateDashboard,
} from "@/modules/api/v2/management/dashboards/[dashboardId]/lib/dashboard";
import {
ZDashboardIdSchema,
ZDashboardUpdateInput,
} from "@/modules/api/v2/management/dashboards/[dashboardId]/types/dashboards";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ dashboardId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ dashboardId: ZDashboardIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
if (!params) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
});
}
const dashboard = await getDashboard(params.dashboardId);
if (!dashboard.ok) {
return handleApiError(request, dashboard.error as ApiErrorResponseV2);
}
if (
!hasProjectPermission(authentication.environmentPermissions, dashboard.data.projectId, "GET")
) {
return handleApiError(request, {
type: "forbidden",
details: [{ field: "dashboard", issue: "does not have permission to access this dashboard" }],
});
}
return responses.successResponse(dashboard);
},
});
export const PUT = async (
request: NextRequest,
props: { params: Promise<{ dashboardId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ dashboardId: ZDashboardIdSchema }),
body: ZDashboardUpdateInput,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params, body } = parsedInput;
if (auditLog) {
auditLog.targetId = params?.dashboardId;
}
if (!body || !params) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: !body ? "body" : "params", issue: "missing" }],
},
auditLog
);
}
const dashboard = await getDashboard(params.dashboardId);
if (!dashboard.ok) {
return handleApiError(request, dashboard.error as ApiErrorResponseV2, auditLog);
}
if (
!hasProjectPermission(authentication.environmentPermissions, dashboard.data.projectId, "PUT")
) {
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "dashboard", issue: "does not have permission to update this dashboard" }],
},
auditLog
);
}
const updatedDashboard = await updateDashboard(params.dashboardId, body);
if (!updatedDashboard.ok) {
return handleApiError(request, updatedDashboard.error as ApiErrorResponseV2, auditLog); // NOSONAR
}
if (auditLog) {
auditLog.oldObject = dashboard.data;
auditLog.newObject = updatedDashboard.data;
}
return responses.successResponse(updatedDashboard);
},
action: "updated",
targetType: "dashboard",
});
export const DELETE = async (
request: NextRequest,
props: { params: Promise<{ dashboardId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ dashboardId: ZDashboardIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput, auditLog }) => {
const { params } = parsedInput;
if (auditLog) {
auditLog.targetId = params?.dashboardId;
}
if (!params) {
return handleApiError(
request,
{
type: "bad_request",
details: [{ field: "params", issue: "missing" }],
},
auditLog
);
}
const dashboard = await getDashboard(params.dashboardId);
if (!dashboard.ok) {
return handleApiError(request, dashboard.error as ApiErrorResponseV2, auditLog);
}
if (
!hasProjectPermission(
authentication.environmentPermissions,
dashboard.data.projectId,
"DELETE"
)
) {
return handleApiError(
request,
{
type: "forbidden",
details: [{ field: "dashboard", issue: "does not have permission to delete this dashboard" }],
},
auditLog
);
}
const deletedDashboard = await deleteDashboard(params.dashboardId);
if (!deletedDashboard.ok) {
return handleApiError(request, deletedDashboard.error as ApiErrorResponseV2, auditLog); // NOSONAR
}
if (auditLog) {
auditLog.oldObject = dashboard.data;
}
return responses.successResponse(deletedDashboard);
},
action: "deleted",
targetType: "dashboard",
});

View File

@@ -1,28 +0,0 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZDashboardIdSchema = z
.string()
.cuid2()
.openapi({
ref: "dashboardId",
description: "The ID of the dashboard",
param: {
name: "id",
in: "path",
},
});
export const ZDashboardUpdateInput = z
.object({
name: z.string().trim().min(1).max(255).optional(),
description: z.string().max(1000).optional().nullable(),
})
.openapi({
ref: "dashboardUpdate",
description: "The fields to update on a dashboard.",
});
export type TDashboardUpdateInput = z.infer<typeof ZDashboardUpdateInput>;

View File

@@ -1,84 +0,0 @@
import { Dashboard, 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 { getDashboardsQuery } from "@/modules/api/v2/management/dashboards/lib/utils";
import {
TDashboardInput,
TGetDashboardsFilter,
} from "@/modules/api/v2/management/dashboards/types/dashboards";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
export const getDashboards = async (
projectIds: string[],
params: TGetDashboardsFilter
): Promise<Result<ApiResponseWithMeta<Dashboard[]>, ApiErrorResponseV2>> => {
try {
const query = getDashboardsQuery(projectIds, params);
const [dashboards, count] = await prisma.$transaction([
prisma.dashboard.findMany({
...query,
}),
prisma.dashboard.count({
where: query.where,
}),
]);
return ok({
data: dashboards,
meta: {
total: count,
limit: params?.limit,
offset: params?.skip,
},
});
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "dashboards", issue: (error as Error).message }],
});
}
};
export const createDashboard = async (
dashboardInput: TDashboardInput,
createdBy?: string
): Promise<Result<Dashboard, ApiErrorResponseV2>> => {
try {
const dashboard = await prisma.dashboard.create({
data: {
name: dashboardInput.name,
description: dashboardInput.description ?? null,
projectId: dashboardInput.projectId,
createdBy: createdBy ?? null,
},
});
return ok(dashboard);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [
{ field: "name", issue: "A dashboard 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: "dashboard", issue: (error as Error).message }],
});
}
};

View File

@@ -1,113 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
TDashboardInput,
TGetDashboardsFilter,
} from "@/modules/api/v2/management/dashboards/types/dashboards";
import { createDashboard, getDashboards } from "../dashboard";
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
dashboard: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
},
}));
describe("getDashboards", () => {
const projectIds = ["project1"];
const params = {
limit: 10,
skip: 0,
};
const fakeDashboards = [
{ id: "d1", projectId: "project1", name: "Dashboard One" },
{ id: "d2", projectId: "project1", name: "Dashboard Two" },
];
const count = fakeDashboards.length;
test("returns ok response with dashboards and meta", async () => {
vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeDashboards, count]);
const result = await getDashboards(projectIds, params as TGetDashboardsFilter);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(fakeDashboards);
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 getDashboards(projectIds, params as TGetDashboardsFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toEqual("internal_server_error");
}
});
});
describe("createDashboard", () => {
const inputDashboard: TDashboardInput = {
projectId: "project1",
name: "New Dashboard",
description: "A new dashboard",
};
const createdDashboard = {
id: "d100",
projectId: inputDashboard.projectId,
name: inputDashboard.name,
description: inputDashboard.description,
createdBy: null,
createdAt: new Date(),
updatedAt: new Date(),
};
test("creates a dashboard", async () => {
vi.mocked(prisma.dashboard.create).mockResolvedValueOnce(createdDashboard);
const result = await createDashboard(inputDashboard);
expect(prisma.dashboard.create).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(createdDashboard);
}
});
test("returns conflict error on duplicate name", async () => {
const { prismaUniqueConstraintError } = await import(
"@/modules/api/v2/management/dashboards/[dashboardId]/lib/tests/mocks/dashboard.mock"
);
vi.mocked(prisma.dashboard.create).mockRejectedValueOnce(prismaUniqueConstraintError);
const result = await createDashboard(inputDashboard);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("conflict");
}
});
test("returns error when creation fails", async () => {
vi.mocked(prisma.dashboard.create).mockRejectedValueOnce(new Error("Creation failed"));
const result = await createDashboard(inputDashboard);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
});

View File

@@ -1,32 +0,0 @@
import { Prisma } from "@prisma/client";
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetDashboardsFilter } from "@/modules/api/v2/management/dashboards/types/dashboards";
export const getDashboardsQuery = (projectIds: string[], params?: TGetDashboardsFilter) => {
let query: Prisma.DashboardFindManyArgs = {
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.DashboardFindManyArgs>(query, baseFilter);
}
return query;
};

View File

@@ -1,97 +0,0 @@
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 { hasProjectPermission } from "@/modules/api/v2/management/charts/lib/utils";
import { createDashboard, getDashboards } from "@/modules/api/v2/management/dashboards/lib/dashboard";
import {
ZDashboardInput,
ZGetDashboardsFilter,
} from "@/modules/api/v2/management/dashboards/types/dashboards";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetDashboardsFilter.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 getDashboards(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: ZDashboardInput,
},
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 dashboard" }],
},
auditLog
);
}
const createDashboardResult = await createDashboard(body);
if (!createDashboardResult.ok) {
return handleApiError(request, createDashboardResult.error, auditLog);
}
if (auditLog) {
auditLog.targetId = createDashboardResult.data.id;
auditLog.newObject = createDashboardResult.data;
}
return responses.createdResponse(createDashboardResult);
},
action: "created",
targetType: "dashboard",
});

View File

@@ -1,26 +0,0 @@
import { z } from "zod";
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
export const ZDashboardInput = z.object({
projectId: z.string().cuid2(),
name: z.string().trim().min(1).max(255),
description: z.string().max(1000).optional(),
});
export type TDashboardInput = z.infer<typeof ZDashboardInput>;
export const ZGetDashboardsFilter = 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 TGetDashboardsFilter = z.infer<typeof ZGetDashboardsFilter>;