feat: organization endpoints (#5076)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
victorvhs017
2025-04-05 08:54:21 -03:00
committed by GitHub
parent cbf2343143
commit c03e60ac0b
74 changed files with 3492 additions and 390 deletions

View File

@@ -62,9 +62,27 @@ describe("getApiKeyWithPermissions", () => {
describe("hasPermission", () => {
const permissions: TAPIKeyEnvironmentPermission[] = [
{ environmentId: "env-1", permission: "manage" },
{ environmentId: "env-2", permission: "write" },
{ environmentId: "env-3", permission: "read" },
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
{
environmentId: "env-2",
permission: "write",
environmentType: "production",
projectId: "project-2",
projectName: "Project 2",
},
{
environmentId: "env-3",
permission: "read",
environmentType: "development",
projectId: "project-3",
projectName: "Project 3",
},
];
it("should return true for manage permission with any method", () => {
@@ -108,7 +126,12 @@ describe("authenticateRequest", () => {
{
environmentId: "env-1",
permission: "manage" as const,
environment: { id: "env-1" },
environment: {
id: "env-1",
projectId: "project-1",
project: { name: "Project 1" },
type: "development",
},
},
],
};
@@ -121,7 +144,15 @@ describe("authenticateRequest", () => {
expect(result).toEqual({
type: "apiKey",
environmentPermissions: [{ environmentId: "env-1", permission: "manage" }],
environmentPermissions: [
{
environmentId: "env-1",
permission: "manage",
environmentType: "development",
projectId: "project-1",
projectName: "Project 1",
},
],
hashedApiKey: "hashed-key",
apiKeyId: "api-key-id",
organizationId: "org-id",

View File

@@ -21,11 +21,15 @@ export const authenticateRequest = async (request: Request): Promise<TAuthentica
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return authentication;

View File

@@ -1,3 +0,0 @@
import { GET } from "@/modules/api/v2/management/roles/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/me/route";
export { GET };

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
export { GET, POST, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
export { GET, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
export { GET, POST };

View File

@@ -0,0 +1,3 @@
import { GET } from "@/modules/api/v2/roles/route";
export { GET };

View File

@@ -11,6 +11,7 @@ export const authenticateRequest = async (
if (!apiKey) return err({ type: "unauthorized" });
const apiKeyData = await getApiKeyWithPermissions(apiKey);
if (!apiKeyData) return err({ type: "unauthorized" });
const hashedApiKey = hashApiKey(apiKey);
@@ -19,11 +20,15 @@ export const authenticateRequest = async (
type: "apiKey",
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
environmentId: env.environmentId,
environmentType: env.environment.type,
permission: env.permission,
projectId: env.environment.projectId,
projectName: env.environment.project.name,
})),
hashedApiKey,
apiKeyId: apiKeyData.id,
organizationId: apiKeyData.organizationId,
organizationAccess: apiKeyData.organizationAccess,
};
return ok(authentication);
};

View File

@@ -1,7 +1,7 @@
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import { apiWrapper } from "@/modules/api/v2/management/auth/api-wrapper";
import { authenticateRequest } from "@/modules/api/v2/management/auth/authenticate-request";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";

View File

@@ -34,12 +34,22 @@ describe("authenticateRequest", () => {
{
environmentId: "env-id-1",
permission: "manage",
environment: { id: "env-id-1" },
environment: {
id: "env-id-1",
projectId: "project-id-1",
type: "development",
project: { name: "Project 1" },
},
},
{
environmentId: "env-id-2",
permission: "read",
environment: { id: "env-id-2" },
environment: {
id: "env-id-2",
projectId: "project-id-2",
type: "production",
project: { name: "Project 2" },
},
},
],
};
@@ -55,8 +65,20 @@ describe("authenticateRequest", () => {
expect(result.data).toEqual({
type: "apiKey",
environmentPermissions: [
{ environmentId: "env-id-1", permission: "manage" },
{ environmentId: "env-id-2", permission: "read" },
{
environmentId: "env-id-1",
permission: "manage",
environmentType: "development",
projectId: "project-id-1",
projectName: "Project 1",
},
{
environmentId: "env-id-2",
permission: "read",
environmentType: "production",
projectId: "project-id-2",
projectName: "Project 2",
},
],
hashedApiKey: "hashed-api-key",
apiKeyId: "api-key-id",

View File

@@ -122,9 +122,11 @@ const notFoundResponse = ({
const conflictResponse = ({
cors = false,
cache = "private, no-store",
details = [],
}: {
cors?: boolean;
cache?: string;
details?: ApiErrorDetails;
} = {}) => {
const headers = {
...(cors && corsHeaders),
@@ -136,6 +138,7 @@ const conflictResponse = ({
error: {
code: 409,
message: "Conflict",
details,
},
},
{

View File

@@ -85,13 +85,15 @@ describe("API Responses", () => {
describe("conflictResponse", () => {
test("return a 409 response", async () => {
const res = responses.conflictResponse();
const details = [{ field: "resource", issue: "already exists" }];
const res = responses.conflictResponse({ details });
expect(res.status).toBe(409);
const body = await res.json();
expect(body).toEqual({
error: {
code: 409,
message: "Conflict",
details,
},
});
});

View File

@@ -16,7 +16,7 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
case "not_found":
return responses.notFoundResponse({ details: err.details });
case "conflict":
return responses.conflictResponse();
return responses.conflictResponse({ details: err.details });
case "unprocessable_entity":
return responses.unprocessableEntityResponse({ details: err.details });
case "too_many_requests":

View File

@@ -9,7 +9,7 @@ export function pickCommonFilter<T extends TGetFilter>(params: T) {
return { limit, skip, sortBy, order, startDate, endDate };
}
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs | Prisma.TeamFindManyArgs | Prisma.ProjectTeamFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate } = params || {};

View File

@@ -1,6 +1,6 @@
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 { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import {
deleteResponse,

View File

@@ -5,7 +5,6 @@ import {
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
@@ -22,7 +21,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
description: "Responses retrieved successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
schema: responseWithMetaSchema(makePartialSchema(ZResponse)),
},
},
},

View File

@@ -1,6 +1,6 @@
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 { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";

View File

@@ -1,26 +0,0 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponse } from "@/modules/api/v2/types/api-success";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getRoles = async (): Promise<Result<ApiResponse<string[]>, ApiErrorResponseV2>> => {
try {
// We use a raw query to get all the roles because we can't list enum options with prisma
const results = await prisma.$queryRaw<{ unnest: string }[]>`
SELECT unnest(enum_range(NULL::"OrganizationRole"));
`;
if (!results) {
// We set internal_server_error because it's an enum and we should always have the roles
return err({ type: "internal_server_error", details: [{ field: "roles", issue: "not found" }] });
}
const roles = results.map((row) => row.unnest);
return ok({
data: roles,
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "roles", issue: error.message }] });
}
};

View File

@@ -1,45 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { getRoles } from "../roles";
// Mock prisma with a $queryRaw function
vi.mock("@formbricks/database", () => ({
prisma: {
$queryRaw: vi.fn(),
},
}));
describe("getRoles", () => {
it("returns roles on success", async () => {
(prisma.$queryRaw as any).mockResolvedValueOnce([{ unnest: "ADMIN" }, { unnest: "MEMBER" }]);
const result = await getRoles();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(["ADMIN", "MEMBER"]);
}
});
it("returns error if no results are found", async () => {
(prisma.$queryRaw as any).mockResolvedValueOnce(null);
const result = await getRoles();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toBe("internal_server_error");
}
});
it("returns error on exception", async () => {
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error("Test DB error"));
const result = await getRoles();
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});

View File

@@ -1,6 +1,6 @@
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 { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";

View File

@@ -11,7 +11,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
description: "Gets a webhook from the database.",
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
id: webhookIdSchema,
}),
},
tags: ["Management API > Webhooks"],
@@ -34,7 +34,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Webhooks"],
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
id: webhookIdSchema,
}),
},
responses: {
@@ -56,7 +56,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Webhooks"],
requestParams: {
path: z.object({
webhookId: webhookIdSchema,
id: webhookIdSchema,
}),
},
requestBody: {

View File

@@ -1,6 +1,6 @@
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 { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
import {
deleteWebhook,

View File

@@ -5,7 +5,6 @@ import {
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/openapi";
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
@@ -22,7 +21,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
description: "Webhooks retrieved successfully.",
content: {
"application/json": {
schema: z.array(responseWithMetaSchema(makePartialSchema(ZWebhook))),
schema: responseWithMetaSchema(makePartialSchema(ZWebhook)),
},
},
},
@@ -60,7 +59,7 @@ export const webhookPaths: ZodOpenApiPathsObject = {
get: getWebhooksEndpoint,
post: createWebhookEndpoint,
},
"/webhooks/{webhookId}": {
"/webhooks/{id}": {
get: getWebhookEndpoint,
put: updateWebhookEndpoint,
delete: deleteWebhookEndpoint,

View File

@@ -1,6 +1,6 @@
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 { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
import { createWebhook, getWebhooks } from "@/modules/api/v2/management/webhooks/lib/webhook";
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";

View File

@@ -0,0 +1,26 @@
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZApiKeyData } from "@formbricks/database/zod/api-keys";
export const getMeEndpoint: ZodOpenApiOperationObject = {
operationId: "me",
summary: "Me",
description: "Fetches the projects and organizations associated with the API key.",
tags: ["Me"],
responses: {
"200": {
description: "API key information retrieved successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZApiKeyData),
},
},
},
},
};
export const mePaths: ZodOpenApiPathsObject = {
"/me": {
get: getMeEndpoint,
},
};

View File

@@ -0,0 +1,23 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
handler: async ({ authentication }) => {
return responses.successResponse({
data: {
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
environmentId: permission.environmentId,
environmentType: permission.environmentType,
permissions: permission.permission,
projectId: permission.projectId,
projectName: permission.projectName,
})),
organizationId: authentication.organizationId,
organizationAccess: authentication.organizationAccess,
},
});
},
});

View File

View File

@@ -2,18 +2,25 @@ import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-at
import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
import { rolePaths } from "@/modules/api/v2/management/roles/lib/openapi";
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
import { mePaths } from "@/modules/api/v2/me/lib/openapi";
import { projectTeamPaths } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi";
import { teamPaths } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/openapi";
import { rolePaths } from "@/modules/api/v2/roles/lib/openapi";
import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
import * as yaml from "yaml";
import { z } from "zod";
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
import { ZApiKeyData } from "@formbricks/database/zod/api-keys";
import { ZContact } from "@formbricks/database/zod/contact";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
import { ZResponse } from "@formbricks/database/zod/responses";
import { ZRoles } from "@formbricks/database/zod/roles";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
import { ZTeam } from "@formbricks/database/zod/teams";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
extendZodWithOpenApi(z);
@@ -26,6 +33,8 @@ const document = createDocument({
version: "2.0.0",
},
paths: {
...rolePaths,
...mePaths,
...responsePaths,
...bulkContactPaths,
...contactPaths,
@@ -33,7 +42,8 @@ const document = createDocument({
...contactAttributeKeyPaths,
...surveyPaths,
...webhookPaths,
...rolePaths,
...teamPaths,
...projectTeamPaths,
},
servers: [
{
@@ -42,6 +52,14 @@ const document = createDocument({
},
],
tags: [
{
name: "Roles",
description: "Operations for managing roles.",
},
{
name: "Me",
description: "Operations for managing your API key.",
},
{
name: "Management API > Responses",
description: "Operations for managing responses.",
@@ -67,8 +85,12 @@ const document = createDocument({
description: "Operations for managing webhooks.",
},
{
name: "Management API > Roles",
description: "Operations for managing roles.",
name: "Organizations API > Teams",
description: "Operations for managing teams.",
},
{
name: "Organizations API > Project Teams",
description: "Operations for managing project teams.",
},
],
components: {
@@ -81,13 +103,16 @@ const document = createDocument({
},
},
schemas: {
role: ZRoles,
me: ZApiKeyData,
response: ZResponse,
contact: ZContact,
contactAttribute: ZContactAttribute,
contactAttributeKey: ZContactAttributeKey,
survey: ZSurveyWithoutQuestionType,
webhook: ZWebhook,
role: z.array(z.string()),
team: ZTeam,
projectTeam: ZProjectTeam,
},
},
security: [

View File

@@ -0,0 +1,55 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { hasOrganizationIdAndAccess } from "./utils";
describe("hasOrganizationIdAndAccess", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("should return false and log error if authentication has no organizationId", () => {
const spyError = vi.spyOn(logger, "error").mockImplementation(() => {});
const authentication = {
organizationAccess: { accessControl: { read: true } },
} as any;
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
expect(result).toBe(false);
expect(spyError).toHaveBeenCalledWith("Organization ID is missing from the authentication object");
});
it("should return false and log error if param organizationId does not match authentication organizationId", () => {
const spyError = vi.spyOn(logger, "error").mockImplementation(() => {});
const authentication = {
organizationId: "org2",
organizationAccess: { accessControl: { read: true } },
} as any;
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
expect(result).toBe(false);
expect(spyError).toHaveBeenCalledWith(
"Organization ID from params does not match the authenticated organization ID"
);
});
it("should return false if access type is missing in organizationAccess", () => {
const authentication = {
organizationId: "org1",
organizationAccess: { accessControl: {} },
} as any;
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
expect(result).toBe(false);
});
it("should return true if organizationId and access type are valid", () => {
const authentication = {
organizationId: "org1",
organizationAccess: { accessControl: { read: true } },
} as any;
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
expect(result).toBe(true);
});
});

View File

@@ -0,0 +1,27 @@
import { logger } from "@formbricks/logger";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
export const hasOrganizationIdAndAccess = (
paramOrganizationId: string,
authentication: TAuthenticationApiKey,
accessType: OrganizationAccessType
): boolean => {
if (!authentication.organizationId) {
logger.error("Organization ID is missing from the authentication object");
return false;
}
if (paramOrganizationId !== authentication.organizationId) {
logger.error("Organization ID from params does not match the authenticated organization ID");
return false;
}
if (!authentication.organizationAccess?.accessControl?.[accessType]) {
return false;
}
return true;
};

View File

@@ -0,0 +1,131 @@
import {
ZGetProjectTeamUpdateFilter,
ZGetProjectTeamsFilter,
ZProjectTeamInput,
projectTeamUpdateSchema,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
operationId: "getProjectTeams",
summary: "Get project teams",
description: "Gets projectTeams from the database.",
requestParams: {
query: ZGetProjectTeamsFilter.sourceType().required(),
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Project Teams"],
responses: {
"200": {
description: "Project teams retrieved successfully.",
content: {
"application/json": {
schema: responseWithMetaSchema(makePartialSchema(ZProjectTeam)),
},
},
},
},
};
export const createProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "createProjectTeam",
summary: "Create a projectTeam",
description: "Creates a project team in the database.",
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Project Teams"],
requestBody: {
required: true,
description: "The project team to create",
content: {
"application/json": {
schema: ZProjectTeamInput,
},
},
},
responses: {
"201": {
description: "Project team created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZProjectTeam),
},
},
},
},
};
export const deleteProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteProjectTeam",
summary: "Delete a project team",
description: "Deletes a project team from the database.",
tags: ["Organizations API > Project Teams"],
requestParams: {
query: ZGetProjectTeamUpdateFilter.required(),
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
responses: {
"200": {
description: "Project team deleted successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZProjectTeam),
},
},
},
},
};
export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "updateProjectTeam",
summary: "Update a project team",
description: "Updates a project team in the database.",
tags: ["Organizations API > Project Teams"],
requestParams: {
query: ZGetProjectTeamUpdateFilter.required(),
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
requestBody: {
required: true,
description: "The project team to update",
content: {
"application/json": {
schema: projectTeamUpdateSchema,
},
},
},
responses: {
"200": {
description: "Project team updated successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZProjectTeam),
},
},
},
},
};
export const projectTeamPaths: ZodOpenApiPathsObject = {
"/{organizationId}/project-teams": {
servers: organizationServer,
get: getProjectTeamsEndpoint,
post: createProjectTeamEndpoint,
put: updateProjectTeamEndpoint,
delete: deleteProjectTeamEndpoint,
},
};

View File

@@ -0,0 +1,130 @@
import { teamCache } from "@/lib/cache/team";
import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import {
TGetProjectTeamsFilter,
TProjectTeamInput,
projectTeamUpdateSchema,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { ProjectTeam } from "@prisma/client";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { projectCache } from "@formbricks/lib/project/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getProjectTeams = async (
organizationId: string,
params: TGetProjectTeamsFilter
): Promise<Result<ApiResponseWithMeta<ProjectTeam[]>, ApiErrorResponseV2>> => {
try {
const [projectTeams, count] = await prisma.$transaction([
prisma.projectTeam.findMany({
...getProjectTeamsQuery(organizationId, params),
}),
prisma.projectTeam.count({
where: getProjectTeamsQuery(organizationId, params).where,
}),
]);
return ok({
data: projectTeams,
meta: {
total: count,
limit: params.limit,
offset: params.skip,
},
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] });
}
};
export const createProjectTeam = async (
teamInput: TProjectTeamInput
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
captureTelemetry("project team created");
const { teamId, projectId, permission } = teamInput;
try {
const projectTeam = await prisma.projectTeam.create({
data: {
teamId,
projectId,
permission,
},
});
projectCache.revalidate({
id: projectId,
});
teamCache.revalidate({
id: teamId,
});
return ok(projectTeam);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] });
}
};
export const updateProjectTeam = async (
teamId: string,
projectId: string,
teamInput: z.infer<typeof projectTeamUpdateSchema>
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
try {
const updatedProjectTeam = await prisma.projectTeam.update({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
data: teamInput,
});
projectCache.revalidate({
id: projectId,
});
teamCache.revalidate({
id: teamId,
});
return ok(updatedProjectTeam);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] });
}
};
export const deleteProjectTeam = async (
teamId: string,
projectId: string
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
try {
const deletedProjectTeam = await prisma.projectTeam.delete({
where: {
projectId_teamId: {
projectId,
teamId,
},
},
});
projectCache.revalidate({
id: projectId,
});
teamCache.revalidate({
id: teamId,
});
return ok(deletedProjectTeam);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] });
}
};

View File

@@ -0,0 +1,134 @@
import {
TGetProjectTeamsFilter,
TProjectTeamInput,
projectTeamUpdateSchema,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TypeOf } from "zod";
import { prisma } from "@formbricks/database";
import { createProjectTeam, deleteProjectTeam, getProjectTeams, updateProjectTeam } from "../project-teams";
vi.mock("@formbricks/database", () => ({
prisma: {
projectTeam: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
$transaction: vi.fn(),
},
}));
describe("ProjectTeams Lib", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getProjectTeams", () => {
it("returns projectTeams with meta on success", async () => {
const mockTeams = [{ id: "projTeam1", organizationId: "orgx", projectId: "p1", teamId: "t1" }];
(prisma.$transaction as any).mockResolvedValueOnce([mockTeams, mockTeams.length]);
const result = await getProjectTeams("orgx", { skip: 0, limit: 10 } as TGetProjectTeamsFilter);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(mockTeams);
expect(result.data.meta).not.toBeNull();
if (result.data.meta) {
expect(result.data.meta.total).toBe(mockTeams.length);
}
}
});
it("returns internal_server_error on exception", async () => {
(prisma.$transaction as any).mockRejectedValueOnce(new Error("DB error"));
const result = await getProjectTeams("orgx", { skip: 0, limit: 10 } as TGetProjectTeamsFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("createProjectTeam", () => {
it("creates a projectTeam successfully", async () => {
const mockCreated = { id: "ptx", projectId: "p1", teamId: "t1", organizationId: "orgx" };
(prisma.projectTeam.create as any).mockResolvedValueOnce(mockCreated);
const result = await createProjectTeam({
projectId: "p1",
teamId: "t1",
} as TProjectTeamInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect((result.data as any).id).toBe("ptx");
}
});
it("returns internal_server_error on error", async () => {
(prisma.projectTeam.create as any).mockRejectedValueOnce(new Error("Create error"));
const result = await createProjectTeam({
projectId: "p1",
teamId: "t1",
} as TProjectTeamInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateProjectTeam", () => {
it("updates a projectTeam successfully", async () => {
(prisma.projectTeam.update as any).mockResolvedValueOnce({
id: "pt01",
projectId: "p1",
teamId: "t1",
permission: "READ",
});
const result = await updateProjectTeam("t1", "p1", { permission: "READ" } as unknown as TypeOf<
typeof projectTeamUpdateSchema
>);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.permission).toBe("READ");
}
});
it("returns internal_server_error on error", async () => {
(prisma.projectTeam.update as any).mockRejectedValueOnce(new Error("Update error"));
const result = await updateProjectTeam("t1", "p1", { permission: "READ" } as unknown as TypeOf<
typeof projectTeamUpdateSchema
>);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("deleteProjectTeam", () => {
it("deletes a projectTeam successfully", async () => {
(prisma.projectTeam.delete as any).mockResolvedValueOnce({
projectId: "p1",
teamId: "t1",
permission: "READ",
});
const result = await deleteProjectTeam("t1", "p1");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.projectId).toBe("p1");
expect(result.data.teamId).toBe("t1");
}
});
it("returns internal_server_error on error", async () => {
(prisma.projectTeam.delete as any).mockRejectedValueOnce(new Error("Delete error"));
const result = await deleteProjectTeam("t1", "p1");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
});

View File

@@ -0,0 +1,113 @@
import { teamCache } from "@/lib/cache/team";
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetProjectTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { cache } from "@formbricks/lib/cache";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { projectCache } from "@formbricks/lib/project/cache";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getProjectTeamsQuery = (organizationId: string, params: TGetProjectTeamsFilter) => {
const { teamId, projectId } = params || {};
let query: Prisma.ProjectTeamFindManyArgs = {
where: {
team: {
organizationId,
},
},
};
if (teamId) {
query = {
...query,
where: {
...query.where,
teamId,
},
};
}
if (projectId) {
query = {
...query,
where: {
...query.where,
projectId,
project: {
organizationId,
},
},
};
}
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.ProjectTeamFindManyArgs>(query, baseFilter);
}
return query;
};
export const validateTeamIdAndProjectId = reactCache(
async (organizationId: string, teamId: string, projectId: string) =>
cache(
async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
try {
const hasAccess = await prisma.organization.findFirst({
where: {
id: organizationId,
teams: {
some: {
id: teamId,
},
},
projects: {
some: {
id: projectId,
},
},
},
});
if (!hasAccess) {
return err({ type: "not_found", details: [{ field: "teamId/projectId", issue: "not_found" }] });
}
return ok(true);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "teamId/projectId", issue: error.message }],
});
}
},
[`validateTeamIdAndProjectId-${organizationId}-${teamId}-${projectId}`],
{
tags: [
teamCache.tag.byId(teamId),
projectCache.tag.byId(projectId),
organizationCache.tag.byId(organizationId),
],
}
)()
);
export const checkAuthenticationAndAccess = async (
teamId: string,
projectId: string,
authentication: TAuthenticationApiKey
): Promise<Result<boolean, ApiErrorResponseV2>> => {
const hasAccess = await validateTeamIdAndProjectId(authentication.organizationId, teamId, projectId);
if (!hasAccess.ok) {
return err(hasAccess.error);
}
return ok(true);
};

View File

@@ -0,0 +1,170 @@
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 { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import { checkAuthenticationAndAccess } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { z } from "zod";
import { OrganizationAccessType } from "@formbricks/types/api-key";
import {
createProjectTeam,
deleteProjectTeam,
getProjectTeams,
updateProjectTeam,
} from "./lib/project-teams";
import {
ZGetProjectTeamUpdateFilter,
ZGetProjectTeamsFilter,
ZProjectTeamInput,
projectTeamUpdateSchema,
} from "./types/project-teams";
export async function GET(request: Request, props: { params: Promise<{ organizationId: string }> }) {
return authenticatedApiClient({
request,
schemas: {
query: ZGetProjectTeamsFilter.sourceType(),
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { query, params }, authentication }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const result = await getProjectTeams(authentication.organizationId, query!);
if (!result.ok) {
return handleApiError(request, result.error);
}
return responses.successResponse(result.data);
},
});
}
export async function POST(request: Request, props: { params: Promise<{ organizationId: string }> }) {
return authenticatedApiClient({
request,
schemas: {
body: ZProjectTeamInput,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { body, params }, authentication }) => {
const { teamId, projectId } = body!;
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
}
// check if project team already exists
const existingProjectTeam = await getProjectTeams(authentication.organizationId, {
teamId,
projectId,
limit: 10,
skip: 0,
sortBy: "createdAt",
order: "desc",
});
if (!existingProjectTeam.ok) {
return handleApiError(request, existingProjectTeam.error);
}
if (existingProjectTeam.data.data.length > 0) {
return handleApiError(request, {
type: "conflict",
details: [{ field: "projectTeam", issue: "Project team already exists" }],
});
}
const result = await createProjectTeam(body!);
if (!result.ok) {
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data, cors: true });
},
});
}
export async function PUT(request: Request, props: { params: Promise<{ organizationId: string }> }) {
return authenticatedApiClient({
request,
schemas: {
body: projectTeamUpdateSchema,
query: ZGetProjectTeamUpdateFilter,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { query, body, params }, authentication }) => {
const { teamId, projectId } = query!;
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
}
const result = await updateProjectTeam(teamId, projectId, body!);
if (!result.ok) {
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data, cors: true });
},
});
}
export async function DELETE(request: Request, props: { params: Promise<{ organizationId: string }> }) {
return authenticatedApiClient({
request,
schemas: {
query: ZGetProjectTeamUpdateFilter,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ parsedInput: { query, params }, authentication }) => {
const { teamId, projectId } = query!;
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
if (!hasAccess.ok) {
return handleApiError(request, hasAccess.error);
}
const result = await deleteProjectTeam(teamId, projectId);
if (!result.ok) {
return handleApiError(request, result.error);
}
return responses.successResponse({ data: result.data, cors: true });
},
});
}

View File

@@ -0,0 +1,37 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
export const ZGetProjectTeamsFilter = ZGetFilter.extend({
teamId: z.string().cuid2().optional(),
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 TGetProjectTeamsFilter = z.infer<typeof ZGetProjectTeamsFilter>;
export const ZProjectTeamInput = ZProjectTeam.pick({
teamId: true,
projectId: true,
permission: true,
});
export type TProjectTeamInput = z.infer<typeof ZProjectTeamInput>;
export const ZGetProjectTeamUpdateFilter = z.object({
teamId: z.string().cuid2(),
projectId: z.string().cuid2(),
});
export const projectTeamUpdateSchema = ZProjectTeam.pick({
permission: true,
});

View File

@@ -0,0 +1,85 @@
import { ZTeamIdSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ZTeamInput } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZTeam } from "@formbricks/database/zod/teams";
export const getTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "getTeam",
summary: "Get a team",
description: "Gets a team from the database.",
requestParams: {
path: z.object({
id: ZTeamIdSchema,
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Teams"],
responses: {
"200": {
description: "Team retrieved successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZTeam),
},
},
},
},
};
export const deleteTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteTeam",
summary: "Delete a team",
description: "Deletes a team from the database.",
tags: ["Organizations API > Teams"],
requestParams: {
path: z.object({
id: ZTeamIdSchema,
organizationId: ZOrganizationIdSchema,
}),
},
responses: {
"200": {
description: "Team deleted successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZTeam),
},
},
},
},
};
export const updateTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "updateTeam",
summary: "Update a team",
description: "Updates a team in the database.",
tags: ["Organizations API > Teams"],
requestParams: {
path: z.object({
id: ZTeamIdSchema,
organizationId: ZOrganizationIdSchema,
}),
},
requestBody: {
required: true,
description: "The team to update",
content: {
"application/json": {
schema: ZTeamInput,
},
},
},
responses: {
"200": {
description: "Team updated successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZTeam),
},
},
},
},
};

View File

@@ -0,0 +1,141 @@
import { organizationCache } from "@/lib/cache/organization";
import { teamCache } from "@/lib/cache/team";
import { ZTeamUpdateSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Team } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { cache } from "@formbricks/lib/cache";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getTeam = reactCache(async (organizationId: string, teamId: string) =>
cache(
async (): Promise<Result<Team, ApiErrorResponseV2>> => {
try {
const responsePrisma = await prisma.team.findUnique({
where: {
id: teamId,
organizationId,
},
});
if (!responsePrisma) {
return err({ type: "not_found", details: [{ field: "team", issue: "not found" }] });
}
return ok(responsePrisma);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "team", issue: error.message }],
});
}
},
[`organizationId-${organizationId}-getTeam-${teamId}`],
{
tags: [teamCache.tag.byId(teamId), organizationCache.tag.byId(organizationId)],
}
)()
);
export const deleteTeam = async (
organizationId: string,
teamId: string
): Promise<Result<Team, ApiErrorResponseV2>> => {
try {
const deletedTeam = await prisma.team.delete({
where: {
id: teamId,
organizationId,
},
include: {
projectTeams: {
select: {
projectId: true,
},
},
},
});
teamCache.revalidate({
id: deletedTeam.id,
organizationId: deletedTeam.organizationId,
});
for (const projectTeam of deletedTeam.projectTeams) {
teamCache.revalidate({
projectId: projectTeam.projectId,
});
}
return ok(deletedTeam);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "team", issue: error.message }],
});
}
};
export const updateTeam = async (
organizationId: string,
teamId: string,
teamInput: z.infer<typeof ZTeamUpdateSchema>
): Promise<Result<Team, ApiErrorResponseV2>> => {
try {
const updatedTeam = await prisma.team.update({
where: {
id: teamId,
organizationId,
},
data: teamInput,
include: {
projectTeams: { select: { projectId: true } },
},
});
teamCache.revalidate({
id: updatedTeam.id,
organizationId: updatedTeam.organizationId,
});
for (const projectTeam of updatedTeam.projectTeams) {
teamCache.revalidate({
projectId: projectTeam.projectId,
});
}
return ok(updatedTeam);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "team", issue: error.message }],
});
}
};

View File

@@ -0,0 +1,166 @@
import { teamCache } from "@/lib/cache/team";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { deleteTeam, getTeam, updateTeam } from "../teams";
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
// Define a mock team
const mockTeam = {
id: "team123",
organizationId: "org456",
name: "Test Team",
projectTeams: [{ projectId: "proj1" }, { projectId: "proj2" }],
};
describe("Teams Lib", () => {
describe("getTeam", () => {
it("returns the team when found", async () => {
(prisma.team.findUnique as any).mockResolvedValueOnce(mockTeam);
const result = await getTeam("org456", "team123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockTeam);
}
expect(prisma.team.findUnique).toHaveBeenCalledWith({
where: { id: "team123", organizationId: "org456" },
});
});
it("returns a not_found error when team is missing", async () => {
(prisma.team.findUnique as any).mockResolvedValueOnce(null);
const result = await getTeam("org456", "team123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
});
it("returns an internal_server_error when prisma throws", async () => {
(prisma.team.findUnique as any).mockRejectedValueOnce(new Error("DB error"));
const result = await getTeam("org456", "team123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("deleteTeam", () => {
it("deletes the team and revalidates cache", async () => {
(prisma.team.delete as any).mockResolvedValueOnce(mockTeam);
// Mock teamCache.revalidate
const revalidateMock = vi.spyOn(teamCache, "revalidate").mockImplementation(() => {});
const result = await deleteTeam("org456", "team123");
expect(prisma.team.delete).toHaveBeenCalledWith({
where: { id: "team123", organizationId: "org456" },
include: { projectTeams: { select: { projectId: true } } },
});
expect(revalidateMock).toHaveBeenCalledWith({
id: mockTeam.id,
organizationId: mockTeam.organizationId,
});
for (const pt of mockTeam.projectTeams) {
expect(revalidateMock).toHaveBeenCalledWith({ projectId: pt.projectId });
}
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockTeam);
}
});
it("returns not_found error on known prisma error", async () => {
(prisma.team.delete as any).mockRejectedValueOnce(
new PrismaClientKnownRequestError("Not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
meta: {},
})
);
const result = await deleteTeam("org456", "team123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
});
it("returns internal_server_error on exception", async () => {
(prisma.team.delete as any).mockRejectedValueOnce(new Error("Delete failed"));
const result = await deleteTeam("org456", "team123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
describe("updateTeam", () => {
const updateInput = { name: "Updated Team" };
const updatedTeam = { ...mockTeam, ...updateInput };
it("updates the team successfully and revalidates cache", async () => {
(prisma.team.update as any).mockResolvedValueOnce(updatedTeam);
const revalidateMock = vi.spyOn(teamCache, "revalidate").mockImplementation(() => {});
const result = await updateTeam("org456", "team123", updateInput);
expect(prisma.team.update).toHaveBeenCalledWith({
where: { id: "team123", organizationId: "org456" },
data: updateInput,
include: { projectTeams: { select: { projectId: true } } },
});
expect(revalidateMock).toHaveBeenCalledWith({
id: updatedTeam.id,
organizationId: updatedTeam.organizationId,
});
for (const pt of updatedTeam.projectTeams) {
expect(revalidateMock).toHaveBeenCalledWith({ projectId: pt.projectId });
}
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(updatedTeam);
}
});
it("returns not_found error when update fails due to missing team", async () => {
(prisma.team.update as any).mockRejectedValueOnce(
new PrismaClientKnownRequestError("Not found", {
code: PrismaErrorType.RecordDoesNotExist,
clientVersion: "1.0.0",
meta: {},
})
);
const result = await updateTeam("org456", "team123", updateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toEqual({
type: "not_found",
details: [{ field: "team", issue: "not found" }],
});
}
});
it("returns internal_server_error on generic exception", async () => {
(prisma.team.update as any).mockRejectedValueOnce(new Error("Update failed"));
const result = await updateTeam("org456", "team123", updateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
}
});
});
});

View File

@@ -0,0 +1,100 @@
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 { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import {
deleteTeam,
getTeam,
updateTeam,
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams";
import {
ZTeamIdSchema,
ZTeamUpdateSchema,
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { z } from "zod";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (
request: Request,
props: { params: Promise<{ teamId: string; organizationId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const team = await getTeam(params!.organizationId, params!.teamId);
if (!team.ok) {
return handleApiError(request, team.error);
}
return responses.successResponse(team);
},
});
export const DELETE = async (
request: Request,
props: { params: Promise<{ teamId: string; organizationId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const team = await deleteTeam(params!.organizationId, params!.teamId);
if (!team.ok) {
return handleApiError(request, team.error);
}
return responses.successResponse(team);
},
});
export const PUT = (
request: Request,
props: { params: Promise<{ teamId: string; organizationId: string }> }
) =>
authenticatedApiClient({
request,
externalParams: props.params,
schemas: {
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
body: ZTeamUpdateSchema,
},
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const team = await updateTeam(params!.organizationId, params!.teamId, body!);
if (!team.ok) {
return handleApiError(request, team.error);
}
return responses.successResponse(team);
},
});

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZTeam } from "@formbricks/database/zod/teams";
extendZodWithOpenApi(z);
export const ZTeamIdSchema = z
.string()
.cuid2()
.openapi({
ref: "teamId",
description: "The ID of the team",
param: {
name: "id",
in: "path",
},
});
export const ZTeamUpdateSchema = ZTeam.omit({
id: true,
createdAt: true,
updatedAt: true,
organizationId: true,
});

View File

@@ -0,0 +1,83 @@
import {
deleteTeamEndpoint,
getTeamEndpoint,
updateTeamEndpoint,
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi";
import {
ZGetTeamsFilter,
ZTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZTeam } from "@formbricks/database/zod/teams";
export const getTeamsEndpoint: ZodOpenApiOperationObject = {
operationId: "getTeams",
summary: "Get teams",
description: "Gets teams from the database.",
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
query: ZGetTeamsFilter.sourceType().required(),
},
tags: ["Organizations API > Teams"],
responses: {
"200": {
description: "Teams retrieved successfully.",
content: {
"application/json": {
schema: responseWithMetaSchema(makePartialSchema(ZTeam)),
},
},
},
},
};
export const createTeamEndpoint: ZodOpenApiOperationObject = {
operationId: "createTeam",
summary: "Create a team",
description: "Creates a team in the database.",
requestParams: {
path: z.object({
organizationId: ZOrganizationIdSchema,
}),
},
tags: ["Organizations API > Teams"],
requestBody: {
required: true,
description: "The team to create",
content: {
"application/json": {
schema: ZTeamInput,
},
},
},
responses: {
"201": {
description: "Team created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZTeam),
},
},
},
},
};
export const teamPaths: ZodOpenApiPathsObject = {
"/{organizationId}/teams": {
servers: organizationServer,
get: getTeamsEndpoint,
post: createTeamEndpoint,
},
"/{organizationId}/teams/{id}": {
servers: organizationServer,
get: getTeamEndpoint,
put: updateTeamEndpoint,
delete: deleteTeamEndpoint,
},
};

View File

@@ -0,0 +1,71 @@
import "server-only";
import { teamCache } from "@/lib/cache/team";
import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils";
import {
TGetTeamsFilter,
TTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { Team } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const createTeam = async (
teamInput: TTeamInput,
organizationId: string
): Promise<Result<Team, ApiErrorResponseV2>> => {
captureTelemetry("team created");
const { name } = teamInput;
try {
const team = await prisma.team.create({
data: {
name,
organizationId,
},
});
organizationCache.revalidate({
id: organizationId,
});
teamCache.revalidate({
organizationId: organizationId,
});
return ok(team);
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "team", issue: error.message }] });
}
};
export const getTeams = async (
organizationId: string,
params: TGetTeamsFilter
): Promise<Result<ApiResponseWithMeta<Team[]>, ApiErrorResponseV2>> => {
try {
const [teams, count] = await prisma.$transaction([
prisma.team.findMany({
...getTeamsQuery(organizationId, params),
}),
prisma.team.count({
where: getTeamsQuery(organizationId, params).where,
}),
]);
return ok({
data: teams,
meta: {
total: count,
limit: params.limit,
offset: params.skip,
},
});
} catch (error) {
return err({ type: "internal_server_error", details: [{ field: "teams", issue: error.message }] });
}
};

View File

@@ -0,0 +1,93 @@
import { TGetTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { organizationCache } from "@formbricks/lib/organization/cache";
import { createTeam, getTeams } from "../teams";
// Define a mock team object
const mockTeam = {
id: "team123",
organizationId: "org456",
name: "Test Team",
};
beforeEach(() => {
vi.clearAllMocks();
});
// Mock prisma methods
vi.mock("@formbricks/database", () => ({
prisma: {
team: {
create: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
$transaction: vi.fn(),
},
}));
// Mock organizationCache.revalidate
vi.spyOn(organizationCache, "revalidate").mockImplementation(() => {});
describe("Teams Lib", () => {
describe("createTeam", () => {
it("creates a team successfully and revalidates cache", async () => {
(prisma.team.create as any).mockResolvedValueOnce(mockTeam);
const teamInput = { name: "Test Team" };
const organizationId = "org456";
const result = await createTeam(teamInput, organizationId);
expect(prisma.team.create).toHaveBeenCalledWith({
data: {
name: "Test Team",
organizationId: organizationId,
},
});
expect(organizationCache.revalidate).toHaveBeenCalledWith({ id: organizationId });
expect(result.ok).toBe(true);
if (result.ok) expect(result.data).toEqual(mockTeam);
});
it("returns internal error when prisma.team.create fails", async () => {
(prisma.team.create as any).mockRejectedValueOnce(new Error("Create error"));
const teamInput = { name: "Test Team" };
const organizationId = "org456";
const result = await createTeam(teamInput, organizationId);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
});
describe("getTeams", () => {
const filter = { limit: 10, skip: 0 };
it("returns teams with meta on success", async () => {
const teamsArray = [mockTeam];
// Simulate prisma transaction return [teams, count]
(prisma.$transaction as any).mockResolvedValueOnce([teamsArray, teamsArray.length]);
const organizationId = "org456";
const result = await getTeams(organizationId, filter as TGetTeamsFilter);
expect(prisma.$transaction).toHaveBeenCalled();
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual({
data: teamsArray,
meta: { total: teamsArray.length, limit: filter.limit, offset: filter.skip },
});
}
});
it("returns internal_server_error when prisma transaction fails", async () => {
(prisma.$transaction as any).mockRejectedValueOnce(new Error("Transaction error"));
const organizationId = "org456";
const result = await getTeams(organizationId, filter as TGetTeamsFilter);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
});
});

View File

@@ -0,0 +1,43 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { Prisma } from "@prisma/client";
import { describe, expect, it, vi } from "vitest";
import { getTeamsQuery } from "../utils";
// Mock the common utils functions
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
pickCommonFilter: vi.fn(),
buildCommonFilterQuery: vi.fn(),
}));
describe("getTeamsQuery", () => {
const organizationId = "org123";
it("returns base query when no params provided", () => {
const result = getTeamsQuery(organizationId);
expect(result.where).toEqual({ organizationId });
});
it("returns unchanged query if pickCommonFilter returns null/undefined", () => {
vi.mocked(pickCommonFilter).mockReturnValueOnce(null as any);
const params: any = { someParam: "test" };
const result = getTeamsQuery(organizationId, params);
expect(pickCommonFilter).toHaveBeenCalledWith(params);
// Since pickCommonFilter returns undefined, query remains as base query.
expect(result.where).toEqual({ organizationId });
});
it("calls buildCommonFilterQuery and returns updated query when base filter exists", () => {
const baseFilter = { key: "value" };
vi.mocked(pickCommonFilter).mockReturnValueOnce(baseFilter as any);
// Simulate buildCommonFilterQuery to merge base query with baseFilter
const updatedQuery = { where: { organizationId, combined: true } } as Prisma.TeamFindManyArgs;
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce(updatedQuery);
const params: any = { someParam: "test" };
const result = getTeamsQuery(organizationId, params);
expect(pickCommonFilter).toHaveBeenCalledWith(params);
expect(buildCommonFilterQuery).toHaveBeenCalledWith({ where: { organizationId } }, baseFilter);
expect(result).toEqual(updatedQuery);
});
});

View File

@@ -0,0 +1,21 @@
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { TGetTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { Prisma } from "@prisma/client";
export const getTeamsQuery = (organizationId: string, params?: TGetTeamsFilter) => {
let query: Prisma.TeamFindManyArgs = {
where: {
organizationId,
},
};
if (!params) return query;
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.TeamFindManyArgs>(query, baseFilter);
}
return query;
};

View File

@@ -0,0 +1,64 @@
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 { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
import { createTeam, getTeams } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/teams";
import {
ZGetTeamsFilter,
ZTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { NextRequest } from "next/server";
import { z } from "zod";
import { OrganizationAccessType } from "@formbricks/types/api-key";
export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetTeamsFilter.sourceType(),
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { query, params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const res = await getTeams(authentication.organizationId, query!);
if (!res.ok) {
return handleApiError(request, res.error);
}
return responses.successResponse(res.data);
},
});
export const POST = async (request: Request, props: { params: Promise<{ organizationId: string }> }) =>
authenticatedApiClient({
request,
schemas: {
body: ZTeamInput,
params: z.object({ organizationId: ZOrganizationIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput: { body, params } }) => {
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "organizationId", issue: "unauthorized" }],
});
}
const createTeamResult = await createTeam(body!, authentication.organizationId);
if (!createTeamResult.ok) {
return handleApiError(request, createTeamResult.error);
}
return responses.successResponse({ data: createTeamResult.data, cors: true });
},
});

View File

@@ -0,0 +1,23 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { ZTeam } from "@formbricks/database/zod/teams";
export const ZGetTeamsFilter = ZGetFilter.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
return false;
}
return true;
},
{
message: "startDate must be before endDate",
}
);
export type TGetTeamsFilter = z.infer<typeof ZGetTeamsFilter>;
export const ZTeamInput = ZTeam.pick({
name: true,
});
export type TTeamInput = z.infer<typeof ZTeamInput>;

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZOrganizationIdSchema = z
.string()
.cuid2()
.openapi({
ref: "organizationId",
description: "The ID of the organization",
param: {
name: "organizationId",
in: "path",
},
});

View File

@@ -0,0 +1,6 @@
export const organizationServer = [
{
url: "https://app.formbricks.com/api/v2/organizations",
description: "Formbricks Cloud",
},
];

View File

@@ -1,18 +1,19 @@
import { z } from "zod";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZRoles } from "@formbricks/database/zod/roles";
export const getRolesEndpoint: ZodOpenApiOperationObject = {
operationId: "getRoles",
summary: "Get roles",
description: "Gets roles from the database.",
requestParams: {},
tags: ["Management API > Roles"],
tags: ["Roles"],
responses: {
"200": {
description: "Roles retrieved successfully.",
content: {
"application/json": {
schema: z.array(z.string()),
schema: makePartialSchema(ZRoles),
},
},
},

View File

@@ -0,0 +1,21 @@
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { OrganizationRole } from "@prisma/client";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getRoles = (): Result<{ data: string[] }, ApiErrorResponseV2> => {
try {
const roles = Object.values(OrganizationRole);
// Filter out the billing role if not in Formbricks Cloud
const filteredRoles = roles.filter((role) => !(role === "billing" && !IS_FORMBRICKS_CLOUD));
return ok({
data: filteredRoles,
});
} catch {
return err({
type: "internal_server_error",
details: [{ field: "roles", issue: "Failed to get roles" }],
});
}
};

View File

@@ -1,14 +1,14 @@
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 { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { getRoles } from "@/modules/api/v2/management/roles/lib/roles";
import { getRoles } from "@/modules/api/v2/roles/lib/utils";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
handler: async () => {
const res = await getRoles();
const res = getRoles();
if (res.ok) {
return responses.successResponse(res.data);

View File

@@ -1,6 +1,6 @@
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 { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
import { upsertBulkContacts } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact";
import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";

View File

@@ -377,6 +377,7 @@ export const AddApiKeyModal = ({
</div>
<div className="flex items-center justify-center py-1">
<Switch
data-testid={`organization-access-${key}-write`}
checked={selectedOrganizationAccess[key].write}
onCheckedChange={(newVal) =>
setSelectedOrganizationAccessValue(key, "write", newVal)

View File

@@ -59,33 +59,50 @@ export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => {
const hashedKey = hashApiKey(apiKey);
return cache(
async () => {
// Look up the API key in the new structure
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
include: {
apiKeyEnvironments: {
include: {
environment: true,
try {
// Look up the API key in the new structure
const apiKeyData = await prisma.apiKey.findUnique({
where: {
hashedKey,
},
include: {
apiKeyEnvironments: {
include: {
environment: {
include: {
project: {
select: {
id: true,
name: true,
},
},
},
},
},
},
},
},
});
});
if (!apiKeyData) return null;
if (!apiKeyData) return null;
// Update the last used timestamp
await prisma.apiKey.update({
where: {
id: apiKeyData.id,
},
data: {
lastUsedAt: new Date(),
},
});
// Update the last used timestamp
await prisma.apiKey.update({
where: {
id: apiKeyData.id,
},
data: {
lastUsedAt: new Date(),
},
});
return apiKeyData;
return apiKeyData;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
},
[`getApiKeyWithPermissions-${apiKey}`],
{

View File

@@ -1,4 +1,9 @@
export const RESPONSES_API_URL = `/api/v2/management/responses`;
export const SURVEYS_API_URL = `/api/v1/management/surveys`;
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
export const ROLES_API_URL = `/api/v2/management/roles`;
export const ROLES_API_URL = `/api/v2/roles`;
export const ME_API_URL = `/api/v2/me`;
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>
`/api/v2/organizations/${organizationId}/project-teams`;

View File

@@ -0,0 +1,120 @@
import { ME_API_URL, PROJECT_TEAMS_API_URL, TEAMS_API_URL } from "@/playwright/api/constants";
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
import { loginAndGetApiKey } from "../../lib/utils";
test.describe("API Tests for ProjectTeams", () => {
test("Create, Retrieve, Update, and Delete ProjectTeams via API", async ({ page, users, request }) => {
let apiKey;
try {
({ apiKey } = await loginAndGetApiKey(page, users));
} catch (error) {
logger.error(error, "Error logging in / retrieving API key");
throw error;
}
let organizationId, projectId, teamId: string;
// Get organization ID using the me endpoint
await test.step("Get Organization ID", async () => {
const response = await request.get(ME_API_URL, {
headers: {
"x-api-key": apiKey,
},
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data).toBeTruthy();
expect(responseBody.data.organizationId).toBeTruthy();
organizationId = responseBody.data.organizationId;
projectId = responseBody.data.environmentPermissions[0].projectId;
});
// Create a team to use for the project team
await test.step("Create Team via API", async () => {
const teamBody = {
organizationId: organizationId,
name: "New Team from API",
};
const response = await request.post(TEAMS_API_URL(organizationId), {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
data: teamBody,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data.name).toEqual("New Team from API");
teamId = responseBody.data.id;
});
await test.step("Create ProjectTeam via API", async () => {
const body = {
projectId: projectId,
teamId: teamId,
permission: "readWrite",
};
const response = await request.post(PROJECT_TEAMS_API_URL(organizationId), {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
data: body,
});
expect(response.ok()).toBe(true);
});
await test.step("Retrieve ProjectTeams via API", async () => {
const queryParams = { teamId: teamId, projectId: projectId };
const response = await request.get(PROJECT_TEAMS_API_URL(organizationId), {
headers: {
"x-api-key": apiKey,
},
params: queryParams,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(
responseBody.data.find((pt: any) => pt.teamId === teamId && pt.projectId === projectId)
).toBeTruthy();
});
await test.step("Update ProjectTeam by ID via API", async () => {
const body = {
permission: "read",
};
const queryParams = { teamId: teamId, projectId: projectId };
const response = await request.put(`${PROJECT_TEAMS_API_URL(organizationId)}`, {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
data: body,
params: queryParams,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data.permission).toBe("read");
});
await test.step("Delete ProjectTeam via API", async () => {
const queryParams = { teamId: teamId, projectId: projectId };
const response = await request.delete(`${PROJECT_TEAMS_API_URL(organizationId)}`, {
headers: {
"x-api-key": apiKey,
},
params: queryParams,
});
expect(response.ok()).toBe(true);
});
});
});

View File

@@ -0,0 +1,108 @@
import { ME_API_URL, TEAMS_API_URL } from "@/playwright/api/constants";
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
import { loginAndGetApiKey } from "../../lib/utils";
test.describe("API Tests for Teams", () => {
test("Create, Retrieve, Update, and Delete Teams via API", async ({ page, users, request }) => {
let apiKey;
try {
({ apiKey } = await loginAndGetApiKey(page, users));
} catch (error) {
logger.error(error, "Error during login and getting API key");
throw error;
}
let organizationId, createdTeamId: string;
// Get organization ID using the me endpoint
await test.step("Get Organization ID", async () => {
const response = await request.get(ME_API_URL, {
headers: {
"x-api-key": apiKey,
},
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data).toBeTruthy();
expect(responseBody.data.organizationId).toBeTruthy();
organizationId = responseBody.data.organizationId;
});
await test.step("Create Team via API", async () => {
const teamBody = {
organizationId: organizationId,
name: "New Team from API",
};
const response = await request.post(TEAMS_API_URL(organizationId), {
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
},
data: teamBody,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data.name).toEqual("New Team from API");
createdTeamId = responseBody.data.id;
});
await test.step("Retrieve Teams via API", async () => {
const queryParams = { limit: 10, skip: 0, sortBy: "createdAt", order: "asc" };
const response = await request.get(TEAMS_API_URL(organizationId), {
headers: {
"x-api-key": apiKey,
},
params: queryParams,
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data.find((team: any) => team.id === createdTeamId)).toBeTruthy();
});
await test.step("Update Team by ID via API", async () => {
const updatedTeamBody = {
name: "Updated Team from API",
};
const response = await request.put(`${TEAMS_API_URL(organizationId)}/${createdTeamId}`, {
headers: {
"x-api-key": apiKey,
"Content-Type": "application/json",
},
data: updatedTeamBody,
});
expect(response.ok()).toBe(true);
const responseJson = await response.json();
expect(responseJson.data.name).toBe("Updated Team from API");
});
await test.step("Get Team by ID from API", async () => {
const response = await request.get(`${TEAMS_API_URL(organizationId)}/${createdTeamId}`, {
headers: {
"x-api-key": apiKey,
},
});
expect(response.ok()).toBe(true);
const responseBody = await response.json();
expect(responseBody.data.id).toEqual(createdTeamId);
expect(responseBody.data.name).toEqual("Updated Team from API");
});
await test.step("Delete Team via API", async () => {
const response = await request.delete(`${TEAMS_API_URL(organizationId)}/${createdTeamId}`, {
headers: {
"x-api-key": apiKey,
},
});
expect(response.ok()).toBe(true);
});
});
});

View File

@@ -1,8 +1,8 @@
import { ROLES_API_URL } from "@/playwright/api/constants";
import { expect } from "@playwright/test";
import { logger } from "@formbricks/logger";
import { test } from "../../lib/fixtures";
import { loginAndGetApiKey } from "../../lib/utils";
import { test } from "../lib/fixtures";
import { loginAndGetApiKey } from "../lib/utils";
test.describe("API Tests for Roles", () => {
test("Retrieve Roles via API", async ({ page, users, request }) => {

View File

@@ -22,6 +22,8 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) {
await page.getByRole("menuitem", { name: "production" }).click();
await page.getByRole("button", { name: "read" }).click();
await page.getByRole("menuitem", { name: "manage" }).click();
await page.getByTestId("organization-access-accessControl-read").click();
await page.getByTestId("organization-access-accessControl-write").click();
await page.getByRole("button", { name: "Add API Key" }).click();
await page.locator(".copyApiKeyIcon").click();

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { type ApiKey, type ApiKeyEnvironment, ApiKeyPermission } from "@prisma/client";
import { type ApiKey, type ApiKeyEnvironment, ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { z } from "zod";
import { ZOrganizationAccess } from "../../types/api-key";
@@ -10,6 +10,9 @@ export const ZApiKeyEnvironment = z.object({
updatedAt: z.date(),
apiKeyId: z.string().cuid2(),
environmentId: z.string().cuid2(),
projectId: z.string().cuid2(),
projectName: z.string(),
environmentType: z.nativeEnum(EnvironmentType),
permission: ZApiKeyPermission,
}) satisfies z.ZodType<ApiKeyEnvironment>;
@@ -37,3 +40,20 @@ export const ZApiKeyEnvironmentCreateInput = z.object({
environmentId: z.string().cuid2(),
permission: ZApiKeyPermission,
});
export const ZApiKeyData = ZApiKey.pick({
organizationId: true,
organizationAccess: true,
}).merge(
z.object({
environments: z.array(
ZApiKeyEnvironment.pick({
environmentId: true,
environmentType: true,
permission: true,
projectId: true,
projectName: true,
})
),
})
);

View File

@@ -0,0 +1,30 @@
import { type ProjectTeam, ProjectTeamPermission } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZProjectTeam = z.object({
createdAt: z.coerce.date().openapi({
description: "The date and time the project tem was created",
example: "2021-01-01T00:00:00.000Z",
}),
updatedAt: z.coerce.date().openapi({
description: "The date and time the project team was last updated",
example: "2021-01-01T00:00:00.000Z",
}),
projectId: z.string().cuid2().openapi({
description: "The ID of the project",
}),
teamId: z.string().cuid2().openapi({
description: "The ID of the team",
}),
permission: z.nativeEnum(ProjectTeamPermission).openapi({
description: "Level of access granted to the project",
}),
}) satisfies z.ZodType<ProjectTeam>;
ZProjectTeam.openapi({
ref: "projectTeam",
description: "A relationship between a project and a team with associated permissions",
});

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZRoles = z.object({
data: z.array(
z.union([z.literal("owner"), z.literal("manager"), z.literal("member"), z.literal("billing")])
),
});

View File

@@ -0,0 +1,31 @@
import type { Team } from "@prisma/client";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
extendZodWithOpenApi(z);
export const ZTeam = z.object({
id: z.string().cuid2().openapi({
description: "The ID of the team",
}),
createdAt: z.coerce.date().openapi({
description: "The date and time the team was created",
example: "2021-01-01T00:00:00.000Z",
}),
updatedAt: z.coerce.date().openapi({
description: "The date and time the team was last updated",
example: "2021-01-01T00:00:00.000Z",
}),
name: z.string().openapi({
description: "The name of the team",
example: "My team",
}),
organizationId: z.string().cuid2().openapi({
description: "The ID of the organization",
}),
}) satisfies z.ZodType<Team>;
ZTeam.openapi({
ref: "team",
description: "A team",
});

View File

@@ -1,11 +1,11 @@
import { z } from "zod";
enum OrganizationAccessType {
export enum OrganizationAccessType {
Read = "read",
Write = "write",
}
enum OrganizationAccess {
export enum OrganizationAccess {
AccessControl = "accessControl",
}

View File

@@ -1,5 +1,6 @@
import { ApiKeyPermission } from "@prisma/client";
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { z } from "zod";
import { ZOrganizationAccess } from "./api-key";
import { ZUser } from "./user";
export const ZAuthSession = z.object({
@@ -8,6 +9,9 @@ export const ZAuthSession = z.object({
export const ZAPIKeyEnvironmentPermission = z.object({
environmentId: z.string(),
environmentType: z.nativeEnum(EnvironmentType),
projectId: z.string().cuid2(),
projectName: z.string(),
permission: z.nativeEnum(ApiKeyPermission),
});
@@ -17,8 +21,9 @@ export const ZAuthenticationApiKey = z.object({
type: z.literal("apiKey"),
environmentPermissions: z.array(ZAPIKeyEnvironmentPermission),
hashedApiKey: z.string(),
apiKeyId: z.string().optional(),
organizationId: z.string().optional(),
apiKeyId: z.string(),
organizationId: z.string(),
organizationAccess: ZOrganizationAccess,
});
export type TAuthSession = z.infer<typeof ZAuthSession>;

60
pnpm-lock.yaml generated
View File

@@ -347,7 +347,7 @@ importers:
version: 0.0.35(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@sentry/nextjs':
specifier: 8.52.0
version: 8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))
version: 8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))
'@tailwindcss/forms':
specifier: 0.5.9
version: 0.5.9(tailwindcss@3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2)))
@@ -452,13 +452,13 @@ importers:
version: 4.0.4
next:
specifier: 15.2.4
version: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
version: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-auth:
specifier: 4.24.11
version: 4.24.11(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nodemailer@6.9.16)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
version: 4.24.11(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nodemailer@6.9.16)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next-safe-action:
specifier: 7.10.2
version: 7.10.2(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1)
version: 7.10.2(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1)
node-fetch:
specifier: 3.3.2
version: 3.3.2
@@ -567,7 +567,7 @@ importers:
version: link:../../packages/config-eslint
'@neshca/cache-handler':
specifier: 1.9.0
version: 1.9.0(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)
version: 1.9.0(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)
'@testing-library/react':
specifier: 16.2.0
version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -16916,11 +16916,11 @@ snapshots:
'@tybys/wasm-util': 0.9.0
optional: true
'@neshca/cache-handler@1.9.0(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)':
'@neshca/cache-handler@1.9.0(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)':
dependencies:
cluster-key-slot: 1.1.2
lru-cache: 10.4.3
next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
redis: 4.7.0
'@next/env@15.2.4': {}
@@ -19136,7 +19136,7 @@ snapshots:
'@sentry/core@8.52.0': {}
'@sentry/nextjs@8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))':
'@sentry/nextjs@8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.30.0
@@ -19149,7 +19149,7 @@ snapshots:
'@sentry/vercel-edge': 8.52.0
'@sentry/webpack-plugin': 2.22.7(encoding@0.1.13)(webpack@5.97.1(esbuild@0.25.2))
chalk: 3.0.0
next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
resolve: 1.22.8
rollup: 3.29.5
stacktrace-parser: 0.1.11
@@ -25940,13 +25940,13 @@ snapshots:
new-github-issue-url@0.2.1: {}
next-auth@4.24.11(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nodemailer@6.9.16)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
next-auth@4.24.11(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nodemailer@6.9.16)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@babel/runtime': 7.27.0
'@panva/hkdf': 1.2.1
cookie: 0.7.2
jose: 4.15.9
next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
oauth: 0.9.15
openid-client: 5.7.1
preact: 10.25.2
@@ -25974,14 +25974,41 @@ snapshots:
optionalDependencies:
nodemailer: 6.10.0
next-safe-action@7.10.2(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1):
next-safe-action@7.10.2(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1):
dependencies:
next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
zod: 3.24.1
next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.2.4
'@swc/counter': 0.1.3
'@swc/helpers': 0.5.15
busboy: 1.6.0
caniuse-lite: 1.0.30001707
postcss: 8.4.31
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0)
optionalDependencies:
'@next/swc-darwin-arm64': 15.2.4
'@next/swc-darwin-x64': 15.2.4
'@next/swc-linux-arm64-gnu': 15.2.4
'@next/swc-linux-arm64-musl': 15.2.4
'@next/swc-linux-x64-gnu': 15.2.4
'@next/swc-linux-x64-musl': 15.2.4
'@next/swc-win32-arm64-msvc': 15.2.4
'@next/swc-win32-x64-msvc': 15.2.4
'@opentelemetry/api': 1.9.0
'@playwright/test': 1.51.1
sharp: 0.33.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
'@next/env': 15.2.4
@@ -28403,6 +28430,13 @@ snapshots:
dependencies:
inline-style-parser: 0.2.4
styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0):
dependencies:
client-only: 0.0.1
react: 19.0.0
optionalDependencies:
'@babel/core': 7.26.0
styled-jsx@5.1.6(react@19.0.0):
dependencies:
client-only: 0.0.1

View File

@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
sonar.sourceEncoding=UTF-8
# Coverage
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**, **/instrumentation.ts, scripts/merge-client-endpoints.ts
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/openapi.ts,**/openapi-document.ts, **/instrumentation.ts, scripts/merge-client-endpoints.ts
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**,**/instrumentation.ts,scripts/merge-client-endpoints.ts
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts