diff --git a/apps/web/app/api/v1/auth.test.ts b/apps/web/app/api/v1/auth.test.ts index b8ed5ccc4b..6659e5583a 100644 --- a/apps/web/app/api/v1/auth.test.ts +++ b/apps/web/app/api/v1/auth.test.ts @@ -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", diff --git a/apps/web/app/api/v1/auth.ts b/apps/web/app/api/v1/auth.ts index 44cd415c69..449f22355c 100644 --- a/apps/web/app/api/v1/auth.ts +++ b/apps/web/app/api/v1/auth.ts @@ -21,11 +21,15 @@ export const authenticateRequest = async (request: Request): Promise ({ 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; diff --git a/apps/web/app/api/v2/management/roles/route.ts b/apps/web/app/api/v2/management/roles/route.ts deleted file mode 100644 index 9580752584..0000000000 --- a/apps/web/app/api/v2/management/roles/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { GET } from "@/modules/api/v2/management/roles/route"; - -export { GET }; diff --git a/apps/web/app/api/v2/me/route.ts b/apps/web/app/api/v2/me/route.ts new file mode 100644 index 0000000000..a9fef632c5 --- /dev/null +++ b/apps/web/app/api/v2/me/route.ts @@ -0,0 +1,3 @@ +import { GET } from "@/modules/api/v2/me/route"; + +export { GET }; diff --git a/apps/web/app/api/v2/organizations/[organizationId]/project-teams/route.ts b/apps/web/app/api/v2/organizations/[organizationId]/project-teams/route.ts new file mode 100644 index 0000000000..b5c025f89b --- /dev/null +++ b/apps/web/app/api/v2/organizations/[organizationId]/project-teams/route.ts @@ -0,0 +1,3 @@ +import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route"; + +export { GET, POST, PUT, DELETE }; diff --git a/apps/web/app/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts b/apps/web/app/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts new file mode 100644 index 0000000000..f5203a194c --- /dev/null +++ b/apps/web/app/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts @@ -0,0 +1,3 @@ +import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route"; + +export { GET, PUT, DELETE }; diff --git a/apps/web/app/api/v2/organizations/[organizationId]/teams/route.ts b/apps/web/app/api/v2/organizations/[organizationId]/teams/route.ts new file mode 100644 index 0000000000..a3f7938e9e --- /dev/null +++ b/apps/web/app/api/v2/organizations/[organizationId]/teams/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route"; + +export { GET, POST }; diff --git a/apps/web/app/api/v2/roles/route.ts b/apps/web/app/api/v2/roles/route.ts new file mode 100644 index 0000000000..09811abca5 --- /dev/null +++ b/apps/web/app/api/v2/roles/route.ts @@ -0,0 +1,3 @@ +import { GET } from "@/modules/api/v2/roles/route"; + +export { GET }; diff --git a/apps/web/modules/api/v2/management/auth/api-wrapper.ts b/apps/web/modules/api/v2/auth/api-wrapper.ts similarity index 100% rename from apps/web/modules/api/v2/management/auth/api-wrapper.ts rename to apps/web/modules/api/v2/auth/api-wrapper.ts diff --git a/apps/web/modules/api/v2/management/auth/authenticate-request.ts b/apps/web/modules/api/v2/auth/authenticate-request.ts similarity index 85% rename from apps/web/modules/api/v2/management/auth/authenticate-request.ts rename to apps/web/modules/api/v2/auth/authenticate-request.ts index 2ec737799e..1eba850cb9 100644 --- a/apps/web/modules/api/v2/management/auth/authenticate-request.ts +++ b/apps/web/modules/api/v2/auth/authenticate-request.ts @@ -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); }; diff --git a/apps/web/modules/api/v2/management/auth/authenticated-api-client.ts b/apps/web/modules/api/v2/auth/authenticated-api-client.ts similarity index 100% rename from apps/web/modules/api/v2/management/auth/authenticated-api-client.ts rename to apps/web/modules/api/v2/auth/authenticated-api-client.ts diff --git a/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts b/apps/web/modules/api/v2/auth/tests/api-wrapper.test.ts similarity index 98% rename from apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts rename to apps/web/modules/api/v2/auth/tests/api-wrapper.test.ts index 33e2a17145..dba952054f 100644 --- a/apps/web/modules/api/v2/management/auth/tests/api-wrapper.test.ts +++ b/apps/web/modules/api/v2/auth/tests/api-wrapper.test.ts @@ -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"; diff --git a/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts b/apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts similarity index 75% rename from apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts rename to apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts index 02f4622e6e..27f4f78cae 100644 --- a/apps/web/modules/api/v2/management/auth/tests/authenticate-request.test.ts +++ b/apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts @@ -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", diff --git a/apps/web/modules/api/v2/management/auth/tests/authenticated-api-client.test.ts b/apps/web/modules/api/v2/auth/tests/authenticated-api-client.test.ts similarity index 100% rename from apps/web/modules/api/v2/management/auth/tests/authenticated-api-client.test.ts rename to apps/web/modules/api/v2/auth/tests/authenticated-api-client.test.ts diff --git a/apps/web/modules/api/v2/lib/response.ts b/apps/web/modules/api/v2/lib/response.ts index e3dc4c03f9..ea4c51c92c 100644 --- a/apps/web/modules/api/v2/lib/response.ts +++ b/apps/web/modules/api/v2/lib/response.ts @@ -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, }, }, { diff --git a/apps/web/modules/api/v2/lib/tests/response.test.ts b/apps/web/modules/api/v2/lib/tests/response.test.ts index d370bc08ce..c5e5d233d9 100644 --- a/apps/web/modules/api/v2/lib/tests/response.test.ts +++ b/apps/web/modules/api/v2/lib/tests/response.test.ts @@ -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, }, }); }); diff --git a/apps/web/modules/api/v2/lib/utils.ts b/apps/web/modules/api/v2/lib/utils.ts index 92c9dbe9cc..845e22a7b6 100644 --- a/apps/web/modules/api/v2/lib/utils.ts +++ b/apps/web/modules/api/v2/lib/utils.ts @@ -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": diff --git a/apps/web/modules/api/v2/management/lib/utils.ts b/apps/web/modules/api/v2/management/lib/utils.ts index 3e601de2cc..33d5eb5fe8 100644 --- a/apps/web/modules/api/v2/management/lib/utils.ts +++ b/apps/web/modules/api/v2/management/lib/utils.ts @@ -9,7 +9,7 @@ export function pickCommonFilter(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(query: T, params: TGetFilter): T { const { limit, skip, sortBy, order, startDate, endDate } = params || {}; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts index 7d3f182c93..d9c6916e62 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -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, diff --git a/apps/web/modules/api/v2/management/responses/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/lib/openapi.ts index e46da37627..eb0dba284b 100644 --- a/apps/web/modules/api/v2/management/responses/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/responses/lib/openapi.ts @@ -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)), }, }, }, diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index d23611f104..3dadae5a75 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -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"; diff --git a/apps/web/modules/api/v2/management/roles/lib/roles.ts b/apps/web/modules/api/v2/management/roles/lib/roles.ts deleted file mode 100644 index 41c022410f..0000000000 --- a/apps/web/modules/api/v2/management/roles/lib/roles.ts +++ /dev/null @@ -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, 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 }] }); - } -}; diff --git a/apps/web/modules/api/v2/management/roles/lib/tests/roles.test.ts b/apps/web/modules/api/v2/management/roles/lib/tests/roles.test.ts deleted file mode 100644 index c23324382e..0000000000 --- a/apps/web/modules/api/v2/management/roles/lib/tests/roles.test.ts +++ /dev/null @@ -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"); - } - }); -}); diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts index 0012d91e47..e22228079c 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts @@ -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"; diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts index 6d0c6e2615..0c5d5cb3d2 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/openapi.ts @@ -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: { diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts index b5da782044..70c810cdf1 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts @@ -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, diff --git a/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts index 92bac070d2..3530e8230c 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts @@ -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, diff --git a/apps/web/modules/api/v2/management/webhooks/route.ts b/apps/web/modules/api/v2/management/webhooks/route.ts index cc5c0f3719..b18ed34a80 100644 --- a/apps/web/modules/api/v2/management/webhooks/route.ts +++ b/apps/web/modules/api/v2/management/webhooks/route.ts @@ -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"; diff --git a/apps/web/modules/api/v2/me/lib/openapi.ts b/apps/web/modules/api/v2/me/lib/openapi.ts new file mode 100644 index 0000000000..f562cdbe97 --- /dev/null +++ b/apps/web/modules/api/v2/me/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/me/route.ts b/apps/web/modules/api/v2/me/route.ts new file mode 100644 index 0000000000..93878ea6dd --- /dev/null +++ b/apps/web/modules/api/v2/me/route.ts @@ -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, + }, + }); + }, + }); diff --git a/apps/web/modules/api/v2/me/types/me.ts b/apps/web/modules/api/v2/me/types/me.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts index da89199cb7..7e8d805e35 100644 --- a/apps/web/modules/api/v2/openapi-document.ts +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -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: [ diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts new file mode 100644 index 0000000000..5a8167b6d9 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts @@ -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); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts new file mode 100644 index 0000000000..59d1016080 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts @@ -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; +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi.ts new file mode 100644 index 0000000000..8a8dc57353 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/project-teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/project-teams.ts new file mode 100644 index 0000000000..1a2bcee222 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/project-teams.ts @@ -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, 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> => { + 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 +): Promise> => { + 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> => { + 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 }] }); + } +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/tests/project-teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/tests/project-teams.test.ts new file mode 100644 index 0000000000..3ced4cf4ba --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/tests/project-teams.test.ts @@ -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"); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils.ts new file mode 100644 index 0000000000..a1cdbea501 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils.ts @@ -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(query, baseFilter); + } + + return query; +}; + +export const validateTeamIdAndProjectId = reactCache( + async (organizationId: string, teamId: string, projectId: string) => + cache( + async (): Promise> => { + 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> => { + const hasAccess = await validateTeamIdAndProjectId(authentication.organizationId, teamId, projectId); + + if (!hasAccess.ok) { + return err(hasAccess.error); + } + + return ok(true); +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts new file mode 100644 index 0000000000..07360dba80 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts @@ -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 }); + }, + }); +} diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams.ts new file mode 100644 index 0000000000..11bf7c4580 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams.ts @@ -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; + +export const ZProjectTeamInput = ZProjectTeam.pick({ + teamId: true, + projectId: true, + permission: true, +}); + +export type TProjectTeamInput = z.infer; + +export const ZGetProjectTeamUpdateFilter = z.object({ + teamId: z.string().cuid2(), + projectId: z.string().cuid2(), +}); + +export const projectTeamUpdateSchema = ZProjectTeam.pick({ + permission: true, +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi.ts new file mode 100644 index 0000000000..18bd73ed56 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi.ts @@ -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), + }, + }, + }, + }, +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts new file mode 100644 index 0000000000..90a9d43c8c --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams.ts @@ -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> => { + 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> => { + 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 +): Promise> => { + 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 }], + }); + } +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts new file mode 100644 index 0000000000..f7ae2215f6 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/tests/teams.test.ts @@ -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"); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts new file mode 100644 index 0000000000..2f12f6aec3 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route.ts @@ -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); + }, + }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams.ts new file mode 100644 index 0000000000..10f6a16dc8 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams.ts @@ -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, +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts new file mode 100644 index 0000000000..443d71ea98 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts @@ -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, + }, +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts new file mode 100644 index 0000000000..db5812b3ef --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts @@ -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> => { + 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, 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 }] }); + } +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts new file mode 100644 index 0000000000..b7da581704 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/teams.test.ts @@ -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"); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/utils.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/utils.test.ts new file mode 100644 index 0000000000..4d77520d2d --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/tests/utils.test.ts @@ -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); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/utils.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/utils.ts new file mode 100644 index 0000000000..1e05db0401 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/utils.ts @@ -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(query, baseFilter); + } + + return query; +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts new file mode 100644 index 0000000000..bf185277ac --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts @@ -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 }); + }, + }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/types/teams.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/types/teams.ts new file mode 100644 index 0000000000..60810b497d --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/types/teams.ts @@ -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; + +export const ZTeamInput = ZTeam.pick({ + name: true, +}); + +export type TTeamInput = z.infer; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/types/organizations.ts b/apps/web/modules/api/v2/organizations/[organizationId]/types/organizations.ts new file mode 100644 index 0000000000..60bc18ab45 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/types/organizations.ts @@ -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", + }, + }); diff --git a/apps/web/modules/api/v2/organizations/lib/openapi.ts b/apps/web/modules/api/v2/organizations/lib/openapi.ts new file mode 100644 index 0000000000..41354cf162 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/lib/openapi.ts @@ -0,0 +1,6 @@ +export const organizationServer = [ + { + url: "https://app.formbricks.com/api/v2/organizations", + description: "Formbricks Cloud", + }, +]; diff --git a/apps/web/modules/api/v2/management/roles/lib/openapi.ts b/apps/web/modules/api/v2/roles/lib/openapi.ts similarity index 72% rename from apps/web/modules/api/v2/management/roles/lib/openapi.ts rename to apps/web/modules/api/v2/roles/lib/openapi.ts index e7e937a924..8f45c1bc12 100644 --- a/apps/web/modules/api/v2/management/roles/lib/openapi.ts +++ b/apps/web/modules/api/v2/roles/lib/openapi.ts @@ -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), }, }, }, diff --git a/apps/web/modules/api/v2/roles/lib/utils.ts b/apps/web/modules/api/v2/roles/lib/utils.ts new file mode 100644 index 0000000000..48eff88d75 --- /dev/null +++ b/apps/web/modules/api/v2/roles/lib/utils.ts @@ -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" }], + }); + } +}; diff --git a/apps/web/modules/api/v2/management/roles/route.ts b/apps/web/modules/api/v2/roles/route.ts similarity index 67% rename from apps/web/modules/api/v2/management/roles/route.ts rename to apps/web/modules/api/v2/roles/route.ts index 829cbc2fe4..3989e1c37f 100644 --- a/apps/web/modules/api/v2/management/roles/route.ts +++ b/apps/web/modules/api/v2/roles/route.ts @@ -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); diff --git a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts index 14286c0f2c..d030a433a6 100644 --- a/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts +++ b/apps/web/modules/ee/contacts/api/v2/management/contacts/bulk/route.ts @@ -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"; diff --git a/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx b/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx index 27f5e32bb9..406b57694d 100644 --- a/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx +++ b/apps/web/modules/organization/settings/api-keys/components/add-api-key-modal.tsx @@ -377,6 +377,7 @@ export const AddApiKeyModal = ({
setSelectedOrganizationAccessValue(key, "write", newVal) diff --git a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts index 45ad8f1b77..0a2c8370bd 100644 --- a/apps/web/modules/organization/settings/api-keys/lib/api-key.ts +++ b/apps/web/modules/organization/settings/api-keys/lib/api-key.ts @@ -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}`], { diff --git a/apps/web/playwright/api/constants.ts b/apps/web/playwright/api/constants.ts index caddcc1ed1..f46f162295 100644 --- a/apps/web/playwright/api/constants.ts +++ b/apps/web/playwright/api/constants.ts @@ -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`; diff --git a/apps/web/playwright/api/organization/project-team.spec.ts b/apps/web/playwright/api/organization/project-team.spec.ts new file mode 100644 index 0000000000..ebf5ce6043 --- /dev/null +++ b/apps/web/playwright/api/organization/project-team.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/web/playwright/api/organization/team.spec.ts b/apps/web/playwright/api/organization/team.spec.ts new file mode 100644 index 0000000000..4573f9dae6 --- /dev/null +++ b/apps/web/playwright/api/organization/team.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/web/playwright/api/management/role.spec.ts b/apps/web/playwright/api/role.spec.ts similarity index 89% rename from apps/web/playwright/api/management/role.spec.ts rename to apps/web/playwright/api/role.spec.ts index 8c02d0fa90..3e639172cf 100644 --- a/apps/web/playwright/api/management/role.spec.ts +++ b/apps/web/playwright/api/role.spec.ts @@ -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 }) => { diff --git a/apps/web/playwright/lib/utils.ts b/apps/web/playwright/lib/utils.ts index 66b33c66a6..8420ec2aef 100644 --- a/apps/web/playwright/lib/utils.ts +++ b/apps/web/playwright/lib/utils.ts @@ -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(); diff --git a/docs/api-v2-reference/openapi.yml b/docs/api-v2-reference/openapi.yml index 62a01e4d24..e448d7f68a 100644 --- a/docs/api-v2-reference/openapi.yml +++ b/docs/api-v2-reference/openapi.yml @@ -7,6 +7,10 @@ servers: - url: https://app.formbricks.com/api/v2/management description: Formbricks Cloud 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. - name: Management API > Contacts @@ -19,8 +23,10 @@ tags: description: Operations for managing surveys. - name: Management API > Webhooks 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. security: - apiKeyAuth: [] paths: @@ -523,6 +529,86 @@ paths: servers: - url: https://app.formbricks.com/api/v2 description: Formbricks API Server + /roles: + get: + operationId: getRoles + summary: Get roles + description: Gets roles from the database. + tags: + - Roles + responses: + "200": + description: Roles retrieved successfully. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: string + /me: + get: + 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: + type: object + properties: + organizationId: + type: string + organizationAccess: + type: object + properties: + accessControl: + type: object + properties: + read: + type: boolean + write: + type: boolean + required: + - read + - write + additionalProperties: false + required: + - accessControl + environments: + type: array + items: + type: object + properties: + environmentId: + type: string + environmentType: + type: string + enum: + - production + - development + permission: + type: string + enum: + - read + - write + - manage + projectId: + type: string + projectName: + type: string + required: + - environmentId + - environmentType + - permission + - projectId + - projectName /responses: get: operationId: getResponses @@ -586,151 +672,149 @@ paths: content: application/json: schema: - type: array - items: - type: object - properties: - data: - type: array - items: - type: object - properties: - id: - type: string - description: The ID of the response - createdAt: - type: string - description: The date and time the response was created - example: 2021-01-01T00:00:00.000Z - updatedAt: - type: string - description: The date and time the response was last updated - example: 2021-01-01T00:00:00.000Z - finished: - type: boolean - description: Whether the response is finished - example: true - surveyId: - type: string - description: The ID of the survey - contactId: - type: - - string - - "null" - description: The ID of the contact - endingId: - type: - - string - - "null" - description: The ID of the ending - data: - type: object - additionalProperties: - anyOf: - - type: string - - type: number - - type: array - items: - type: string - - type: object - additionalProperties: - type: string - description: The data of the response - example: &a1 - question1: answer1 - question2: 2 - question3: - - answer3 - - answer4 - question4: - subquestion1: answer5 - variables: - type: object - additionalProperties: - anyOf: - - type: string - - type: number - description: The variables of the response - example: &a2 - variable1: answer1 - variable2: 2 - ttc: - type: object - additionalProperties: - type: number - description: The TTC of the response - example: &a3 - question1: 10 - question2: 20 - meta: - type: object - properties: - source: - type: string - description: The source of the response - example: https://example.com - url: - type: string - description: The URL of the response - example: https://example.com - userAgent: - type: object - properties: - browser: - type: string - os: - type: string - device: - type: string - country: - type: string - action: - type: string - description: The meta data of the response - example: &a4 - source: https://example.com - url: https://example.com - userAgent: - browser: Chrome - os: Windows - device: Desktop - country: US - action: click - contactAttributes: - type: - - object - - "null" - additionalProperties: - type: string - description: The attributes of the contact - example: &a5 - attribute1: value1 - attribute2: value2 - singleUseId: - type: - - string - - "null" - description: The single use ID of the response - language: - type: - - string - - "null" - description: The language of the response - example: en - displayId: - type: - - string - - "null" - description: The display ID of the response - meta: + type: object + properties: + data: + type: array + items: type: object properties: - total: - type: number - limit: - type: number - offset: - type: number + id: + type: string + description: The ID of the response + createdAt: + type: string + description: The date and time the response was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the response was last updated + example: 2021-01-01T00:00:00.000Z + finished: + type: boolean + description: Whether the response is finished + example: true + surveyId: + type: string + description: The ID of the survey + contactId: + type: + - string + - "null" + description: The ID of the contact + endingId: + type: + - string + - "null" + description: The ID of the ending + data: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + - type: array + items: + type: string + - type: object + additionalProperties: + type: string + description: The data of the response + example: &a1 + question1: answer1 + question2: 2 + question3: + - answer3 + - answer4 + question4: + subquestion1: answer5 + variables: + type: object + additionalProperties: + anyOf: + - type: string + - type: number + description: The variables of the response + example: &a2 + variable1: answer1 + variable2: 2 + ttc: + type: object + additionalProperties: + type: number + description: The TTC of the response + example: &a3 + question1: 10 + question2: 20 + meta: + type: object + properties: + source: + type: string + description: The source of the response + example: https://example.com + url: + type: string + description: The URL of the response + example: https://example.com + userAgent: + type: object + properties: + browser: + type: string + os: + type: string + device: + type: string + country: + type: string + action: + type: string + description: The meta data of the response + example: &a4 + source: https://example.com + url: https://example.com + userAgent: + browser: Chrome + os: Windows + device: Desktop + country: US + action: click + contactAttributes: + type: + - object + - "null" + additionalProperties: + type: string + description: The attributes of the contact + example: &a5 + attribute1: value1 + attribute2: value2 + singleUseId: + type: + - string + - "null" + description: The single use ID of the response + language: + type: + - string + - "null" + description: The language of the response + example: en + displayId: + type: + - string + - "null" + description: The display ID of the response + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number post: operationId: createResponse summary: Create a response @@ -1418,6 +1502,106 @@ paths: - string - "null" description: The display ID of the response + /contacts/bulk: + put: + operationId: uploadBulkContacts + summary: Upload Bulk Contacts + description: Uploads contacts in bulk + tags: + - Management API > Contacts + requestBody: + required: true + description: The contacts to upload + content: + application/json: + schema: + type: object + properties: + environmentId: + type: string + contacts: + type: array + items: + type: object + properties: + attributes: + type: array + items: + type: object + properties: + attributeKey: + type: object + properties: + key: + type: string + name: + type: string + required: + - key + - name + value: + type: string + required: + - attributeKey + - value + required: + - attributes + maxItems: 1000 + required: + - environmentId + - contacts + responses: + "200": + description: Contacts uploaded successfully. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + status: + type: string + message: + type: string + required: + - status + - message + required: + - data + "207": + description: Contacts uploaded partially successfully. + content: + application/json: + schema: + type: object + properties: + data: + type: object + properties: + status: + type: string + message: + type: string + skippedContacts: + type: array + items: + type: object + properties: + index: + type: number + userId: + type: string + required: + - index + - userId + required: + - status + - message + - skippedContacts + required: + - data /contacts: get: operationId: getContacts @@ -2099,69 +2283,67 @@ paths: content: application/json: schema: - type: array - items: - type: object - properties: - data: - type: array - items: - type: object - properties: - id: - type: string - description: The ID of the webhook - name: - type: - - string - - "null" - description: The name of the webhook - createdAt: - type: string - description: The date and time the webhook was created - example: 2021-01-01T00:00:00.000Z - updatedAt: - type: string - description: The date and time the webhook was last updated - example: 2021-01-01T00:00:00.000Z - url: - type: string - format: uri - description: The URL of the webhook - source: - type: string - enum: &a8 - - user - - zapier - - make - - n8n - description: The source of the webhook - environmentId: - type: string - description: The ID of the environment - triggers: - type: array - items: - type: string - enum: &a9 - - responseFinished - - responseCreated - - responseUpdated - description: The triggers of the webhook - surveyIds: - type: array - items: - type: string - description: "The IDs of the surveys " - meta: + type: object + properties: + data: + type: array + items: type: object properties: - total: - type: number - limit: - type: number - offset: - type: number + id: + type: string + description: The ID of the webhook + name: + type: + - string + - "null" + description: The name of the webhook + createdAt: + type: string + description: The date and time the webhook was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the webhook was last updated + example: 2021-01-01T00:00:00.000Z + url: + type: string + format: uri + description: The URL of the webhook + source: + type: string + enum: &a8 + - user + - zapier + - make + - n8n + description: The source of the webhook + environmentId: + type: string + description: The ID of the environment + triggers: + type: array + items: + type: string + enum: &a9 + - responseFinished + - responseCreated + - responseUpdated + description: The triggers of the webhook + surveyIds: + type: array + items: + type: string + description: "The IDs of the surveys " + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number post: operationId: createWebhook summary: Create a webhook @@ -2256,7 +2438,7 @@ paths: items: type: string description: "The IDs of the surveys " - /webhooks/{webhookId}: + /webhooks/{id}: get: operationId: getWebhook summary: Get a webhook @@ -2476,22 +2658,593 @@ paths: items: type: string description: "The IDs of the surveys " - /roles: + /{organizationId}/teams: + servers: &a10 + - url: https://app.formbricks.com/api/v2/organizations + description: Formbricks Cloud get: - operationId: getRoles - summary: Get roles - description: Gets roles from the database. + operationId: getTeams + summary: Get teams + description: Gets teams from the database. tags: - - Management API > Roles + - Organizations API > Teams + parameters: + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + - in: query + name: limit + schema: + type: number + minimum: 1 + maximum: 100 + default: 10 + - in: query + name: skip + schema: + type: number + minimum: 0 + default: 0 + - in: query + name: sortBy + schema: + type: string + enum: *a6 + default: createdAt + - in: query + name: order + schema: + type: string + enum: *a7 + default: desc + - in: query + name: startDate + schema: + type: string + required: true + - in: query + name: endDate + schema: + type: string + required: true responses: "200": - description: Roles retrieved successfully. + description: Teams retrieved successfully. content: application/json: schema: - type: array - items: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + description: The ID of the team + createdAt: + type: string + description: The date and time the team was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the team was last updated + example: 2021-01-01T00:00:00.000Z + name: + type: string + description: The name of the team + example: My team + organizationId: + type: string + description: The ID of the organization + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + post: + operationId: createTeam + summary: Create a team + description: Creates a team in the database. + tags: + - Organizations API > Teams + parameters: + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + requestBody: + required: true + description: The team to create + content: + application/json: + schema: + type: object + properties: + name: type: string + description: The name of the team + example: My team + required: + - name + responses: + "201": + description: Team created successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the team + createdAt: + type: string + description: The date and time the team was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the team was last updated + example: 2021-01-01T00:00:00.000Z + name: + type: string + description: The name of the team + example: My team + organizationId: + type: string + description: The ID of the organization + /{organizationId}/teams/{id}: + servers: *a10 + get: + operationId: getTeam + summary: Get a team + description: Gets a team from the database. + tags: + - Organizations API > Teams + parameters: + - in: path + name: id + description: The ID of the team + schema: + $ref: "#/components/schemas/teamId" + required: true + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + responses: + "200": + description: Team retrieved successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the team + createdAt: + type: string + description: The date and time the team was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the team was last updated + example: 2021-01-01T00:00:00.000Z + name: + type: string + description: The name of the team + example: My team + organizationId: + type: string + description: The ID of the organization + put: + operationId: updateTeam + summary: Update a team + description: Updates a team in the database. + tags: + - Organizations API > Teams + parameters: + - in: path + name: id + description: The ID of the team + schema: + $ref: "#/components/schemas/teamId" + required: true + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + requestBody: + required: true + description: The team to update + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: The name of the team + example: My team + required: + - name + responses: + "200": + description: Team updated successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the team + createdAt: + type: string + description: The date and time the team was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the team was last updated + example: 2021-01-01T00:00:00.000Z + name: + type: string + description: The name of the team + example: My team + organizationId: + type: string + description: The ID of the organization + delete: + operationId: deleteTeam + summary: Delete a team + description: Deletes a team from the database. + tags: + - Organizations API > Teams + parameters: + - in: path + name: id + description: The ID of the team + schema: + $ref: "#/components/schemas/teamId" + required: true + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + responses: + "200": + description: Team deleted successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the team + createdAt: + type: string + description: The date and time the team was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the team was last updated + example: 2021-01-01T00:00:00.000Z + name: + type: string + description: The name of the team + example: My team + organizationId: + type: string + description: The ID of the organization + /{organizationId}/project-teams: + servers: *a10 + get: + operationId: getProjectTeams + summary: Get project teams + description: Gets projectTeams from the database. + tags: + - Organizations API > Project Teams + parameters: + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + - in: query + name: limit + schema: + type: number + minimum: 1 + maximum: 100 + default: 10 + - in: query + name: skip + schema: + type: number + minimum: 0 + default: 0 + - in: query + name: sortBy + schema: + type: string + enum: *a6 + default: createdAt + - in: query + name: order + schema: + type: string + enum: *a7 + default: desc + - in: query + name: startDate + schema: + type: string + required: true + - in: query + name: endDate + schema: + type: string + required: true + - in: query + name: teamId + schema: + type: string + required: true + - in: query + name: projectId + schema: + type: string + required: true + responses: + "200": + description: Project teams retrieved successfully. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + createdAt: + type: string + description: The date and time the project tem was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the project team was last updated + example: 2021-01-01T00:00:00.000Z + projectId: + type: string + description: The ID of the project + teamId: + type: string + description: The ID of the team + permission: + type: string + enum: + - read + - readWrite + - manage + description: Level of access granted to the project + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + post: + operationId: createProjectTeam + summary: Create a projectTeam + description: Creates a project team in the database. + tags: + - Organizations API > Project Teams + parameters: + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + requestBody: + required: true + description: The project team to create + content: + application/json: + schema: + type: object + properties: + teamId: + type: string + description: The ID of the team + projectId: + type: string + description: The ID of the project + permission: + type: string + enum: + - read + - readWrite + - manage + description: Level of access granted to the project + required: + - teamId + - projectId + - permission + responses: + "201": + description: Project team created successfully. + content: + application/json: + schema: + type: object + properties: + createdAt: + type: string + description: The date and time the project tem was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the project team was last updated + example: 2021-01-01T00:00:00.000Z + projectId: + type: string + description: The ID of the project + teamId: + type: string + description: The ID of the team + permission: + type: string + enum: + - read + - readWrite + - manage + description: Level of access granted to the project + put: + operationId: updateProjectTeam + summary: Update a project team + description: Updates a project team in the database. + tags: + - Organizations API > Project Teams + parameters: + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + - in: query + name: teamId + schema: + type: string + required: true + - in: query + name: projectId + schema: + type: string + required: true + requestBody: + required: true + description: The project team to update + content: + application/json: + schema: + type: object + properties: + permission: + type: string + enum: + - read + - readWrite + - manage + description: Level of access granted to the project + required: + - permission + responses: + "200": + description: Project team updated successfully. + content: + application/json: + schema: + type: object + properties: + createdAt: + type: string + description: The date and time the project tem was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the project team was last updated + example: 2021-01-01T00:00:00.000Z + projectId: + type: string + description: The ID of the project + teamId: + type: string + description: The ID of the team + permission: + type: string + enum: + - read + - readWrite + - manage + description: Level of access granted to the project + delete: + operationId: deleteProjectTeam + summary: Delete a project team + description: Deletes a project team from the database. + tags: + - Organizations API > Project Teams + parameters: + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + - in: query + name: teamId + schema: + type: string + required: true + - in: query + name: projectId + schema: + type: string + required: true + responses: + "200": + description: Project team deleted successfully. + content: + application/json: + schema: + type: object + properties: + createdAt: + type: string + description: The date and time the project tem was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the project team was last updated + example: 2021-01-01T00:00:00.000Z + projectId: + type: string + description: The ID of the project + teamId: + type: string + description: The ID of the team + permission: + type: string + enum: + - read + - readWrite + - manage + description: Level of access granted to the project components: securitySchemes: apiKeyAuth: @@ -2500,6 +3253,68 @@ components: name: x-api-key description: Use your Formbricks x-api-key to authenticate. schemas: + role: + type: object + properties: + data: + type: array + items: + type: string + required: + - data + me: + type: object + properties: + organizationId: + type: string + organizationAccess: + type: object + properties: + accessControl: + type: object + properties: + read: + type: boolean + write: + type: boolean + required: + - read + - write + additionalProperties: false + required: + - accessControl + environments: + type: array + items: + type: object + properties: + environmentId: + type: string + environmentType: + type: string + enum: + - production + - development + permission: + type: string + enum: + - read + - write + - manage + projectId: + type: string + projectName: + type: string + required: + - environmentId + - environmentType + - permission + - projectId + - projectName + required: + - organizationId + - organizationAccess + - environments response: type: object properties: @@ -2893,7 +3708,7 @@ components: required: - id - type - default: &a11 [] + default: &a12 [] description: The endings of the survey thankYouCard: type: @@ -2961,7 +3776,7 @@ components: description: Survey variables displayOption: type: string - enum: &a12 + enum: &a13 - displayOnce - displayMultiple - displaySome @@ -3040,12 +3855,13 @@ components: type: - string - "null" - enum: &a14 + enum: - bottomLeft - bottomRight - topLeft - topRight - center + - null clickOutsideClose: type: - boolean @@ -3195,13 +4011,13 @@ components: properties: linkSurveys: type: string - enum: &a10 + enum: &a11 - casual - straight - simple appSurveys: type: string - enum: *a10 + enum: *a11 required: - linkSurveys - appSurveys @@ -3218,11 +4034,12 @@ components: type: - string - "null" - enum: &a13 + enum: - animation - color - image - upload + - null brightness: type: - number @@ -3374,10 +4191,63 @@ components: - environmentId - triggers - surveyIds - role: - type: array - items: - type: string + team: + type: object + properties: + id: + type: string + description: The ID of the team + createdAt: + type: string + description: The date and time the team was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the team was last updated + example: 2021-01-01T00:00:00.000Z + name: + type: string + description: The name of the team + example: My team + organizationId: + type: string + description: The ID of the organization + required: + - id + - createdAt + - updatedAt + - name + - organizationId + projectTeam: + type: object + properties: + createdAt: + type: string + description: The date and time the project tem was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the project team was last updated + example: 2021-01-01T00:00:00.000Z + projectId: + type: string + description: The ID of the project + teamId: + type: string + description: The ID of the team + permission: + type: string + enum: + - read + - readWrite + - manage + description: Level of access granted to the project + required: + - createdAt + - updatedAt + - projectId + - teamId + - permission responseId: type: string description: The ID of the response @@ -3523,7 +4393,7 @@ components: required: - id - type - default: *a11 + default: *a12 description: The endings of the survey thankYouCard: type: @@ -3589,7 +4459,7 @@ components: description: Survey variables displayOption: type: string - enum: *a12 + enum: *a13 description: Display options for the survey recontactDays: type: @@ -3849,10 +4719,10 @@ components: properties: linkSurveys: type: string - enum: *a10 + enum: *a11 appSurveys: type: string - enum: *a10 + enum: *a11 required: - linkSurveys - appSurveys @@ -3869,7 +4739,12 @@ components: type: - string - "null" - enum: *a13 + enum: + - animation + - color + - image + - upload + - null brightness: type: - number @@ -3902,7 +4777,13 @@ components: type: - string - "null" - enum: *a14 + enum: + - bottomLeft + - bottomRight + - topLeft + - topRight + - center + - null clickOutsideClose: type: - boolean @@ -3936,3 +4817,9 @@ components: webhookId: type: string description: The ID of the webhook + organizationId: + type: string + description: The ID of the organization + teamId: + type: string + description: The ID of the team diff --git a/packages/database/zod/api-keys.ts b/packages/database/zod/api-keys.ts index db15dedde0..b15871f13e 100644 --- a/packages/database/zod/api-keys.ts +++ b/packages/database/zod/api-keys.ts @@ -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; @@ -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, + }) + ), + }) +); diff --git a/packages/database/zod/project-teams.ts b/packages/database/zod/project-teams.ts new file mode 100644 index 0000000000..49647f8ec6 --- /dev/null +++ b/packages/database/zod/project-teams.ts @@ -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; + +ZProjectTeam.openapi({ + ref: "projectTeam", + description: "A relationship between a project and a team with associated permissions", +}); diff --git a/packages/database/zod/roles.ts b/packages/database/zod/roles.ts new file mode 100644 index 0000000000..e085e4005f --- /dev/null +++ b/packages/database/zod/roles.ts @@ -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")]) + ), +}); diff --git a/packages/database/zod/teams.ts b/packages/database/zod/teams.ts new file mode 100644 index 0000000000..d24e752cd7 --- /dev/null +++ b/packages/database/zod/teams.ts @@ -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; + +ZTeam.openapi({ + ref: "team", + description: "A team", +}); diff --git a/packages/types/api-key.ts b/packages/types/api-key.ts index fbcb5a4df1..9e6731f0d5 100644 --- a/packages/types/api-key.ts +++ b/packages/types/api-key.ts @@ -1,11 +1,11 @@ import { z } from "zod"; -enum OrganizationAccessType { +export enum OrganizationAccessType { Read = "read", Write = "write", } -enum OrganizationAccess { +export enum OrganizationAccess { AccessControl = "accessControl", } diff --git a/packages/types/auth.ts b/packages/types/auth.ts index aef9184868..47b15a9ebd 100644 --- a/packages/types/auth.ts +++ b/packages/types/auth.ts @@ -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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2768aebcb2..8ee8f0372b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/sonar-project.properties b/sonar-project.properties index 3e6fde6dd1..bcce9834f1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -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 \ No newline at end of file +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 \ No newline at end of file