diff --git a/apps/web/app/api/v2/organizations/[organizationId]/users/route.ts b/apps/web/app/api/v2/organizations/[organizationId]/users/route.ts new file mode 100644 index 0000000000..1139ba0e5c --- /dev/null +++ b/apps/web/app/api/v2/organizations/[organizationId]/users/route.ts @@ -0,0 +1,3 @@ +import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route"; + +export { GET, POST, PATCH }; diff --git a/apps/web/modules/api/v2/lib/response.ts b/apps/web/modules/api/v2/lib/response.ts index ea4c51c92c..a378f75a36 100644 --- a/apps/web/modules/api/v2/lib/response.ts +++ b/apps/web/modules/api/v2/lib/response.ts @@ -235,7 +235,7 @@ const internalServerErrorResponse = ({ const successResponse = ({ data, meta, - cors = false, + cors = true, cache = "private, no-store", }: { data: Object; diff --git a/apps/web/modules/api/v2/management/lib/utils.ts b/apps/web/modules/api/v2/management/lib/utils.ts index bc10c929d7..105cda6122 100644 --- a/apps/web/modules/api/v2/management/lib/utils.ts +++ b/apps/web/modules/api/v2/management/lib/utils.ts @@ -13,7 +13,8 @@ type HasFindMany = | Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs | Prisma.TeamFindManyArgs - | Prisma.ProjectTeamFindManyArgs; + | Prisma.ProjectTeamFindManyArgs + | Prisma.UserFindManyArgs; 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]/lib/openapi.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts index de2ae256b9..c91a2fc836 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/openapi.ts @@ -1,4 +1,4 @@ -import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses"; +import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses"; import { makePartialSchema } from "@/modules/api/v2/types/openapi-response"; import { z } from "zod"; import { ZodOpenApiOperationObject } from "zod-openapi"; @@ -11,7 +11,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = { description: "Gets a response from the database.", requestParams: { path: z.object({ - id: responseIdSchema, + id: ZResponseIdSchema, }), }, tags: ["Management API > Responses"], @@ -34,7 +34,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = { tags: ["Management API > Responses"], requestParams: { path: z.object({ - id: responseIdSchema, + id: ZResponseIdSchema, }), }, responses: { @@ -56,7 +56,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = { tags: ["Management API > Responses"], requestParams: { path: z.object({ - id: responseIdSchema, + id: ZResponseIdSchema, }), }, requestBody: { diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts b/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts index 2a4f0b8bab..a9d890fe28 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/lib/response.ts @@ -1,7 +1,7 @@ import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display"; import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey"; import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils"; -import { responseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses"; +import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { Response } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -98,7 +98,7 @@ export const deleteResponse = async (responseId: string): Promise + responseInput: z.infer ): Promise> => { try { const updatedResponse = await prisma.response.update({ 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 d9c6916e62..f71b66d7b1 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/route.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/route.ts @@ -9,13 +9,13 @@ import { } from "@/modules/api/v2/management/responses/[responseId]/lib/response"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { z } from "zod"; -import { responseIdSchema, responseUpdateSchema } from "./types/responses"; +import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses"; export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) => authenticatedApiClient({ request, schemas: { - params: z.object({ responseId: responseIdSchema }), + params: z.object({ responseId: ZResponseIdSchema }), }, externalParams: props.params, handler: async ({ authentication, parsedInput }) => { @@ -52,7 +52,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon authenticatedApiClient({ request, schemas: { - params: z.object({ responseId: responseIdSchema }), + params: z.object({ responseId: ZResponseIdSchema }), }, externalParams: props.params, handler: async ({ authentication, parsedInput }) => { @@ -91,8 +91,8 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str request, externalParams: props.params, schemas: { - params: z.object({ responseId: responseIdSchema }), - body: responseUpdateSchema, + params: z.object({ responseId: ZResponseIdSchema }), + body: ZResponseUpdateSchema, }, handler: async ({ authentication, parsedInput }) => { const { body, params } = parsedInput; diff --git a/apps/web/modules/api/v2/management/responses/[responseId]/types/responses.ts b/apps/web/modules/api/v2/management/responses/[responseId]/types/responses.ts index 68118bdbe2..8115c028b3 100644 --- a/apps/web/modules/api/v2/management/responses/[responseId]/types/responses.ts +++ b/apps/web/modules/api/v2/management/responses/[responseId]/types/responses.ts @@ -4,7 +4,7 @@ import { ZResponse } from "@formbricks/database/zod/responses"; extendZodWithOpenApi(z); -export const responseIdSchema = z +export const ZResponseIdSchema = z .string() .cuid2() .openapi({ @@ -16,7 +16,7 @@ export const responseIdSchema = z }, }); -export const responseUpdateSchema = ZResponse.omit({ +export const ZResponseUpdateSchema = ZResponse.omit({ id: true, surveyId: true, }).openapi({ 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 eb0dba284b..b1529cfaac 100644 --- a/apps/web/modules/api/v2/management/responses/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/responses/lib/openapi.ts @@ -13,7 +13,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = { summary: "Get responses", description: "Gets responses from the database.", requestParams: { - query: ZGetResponsesFilter.sourceType().required(), + query: ZGetResponsesFilter.sourceType(), }, tags: ["Management API > Responses"], responses: { diff --git a/apps/web/modules/api/v2/management/responses/lib/response.ts b/apps/web/modules/api/v2/management/responses/lib/response.ts index 5ba79f8408..2eb80bf9ed 100644 --- a/apps/web/modules/api/v2/management/responses/lib/response.ts +++ b/apps/web/modules/api/v2/management/responses/lib/response.ts @@ -134,12 +134,14 @@ export const getResponses = async ( params: TGetResponsesFilter ): Promise, ApiErrorResponseV2>> => { try { + const query = getResponsesQuery(environmentIds, params); + const [responses, count] = await prisma.$transaction([ prisma.response.findMany({ - ...getResponsesQuery(environmentIds, params), + ...query, }), prisma.response.count({ - where: getResponsesQuery(environmentIds, params).where, + where: query.where, }), ]); diff --git a/apps/web/modules/api/v2/management/responses/route.ts b/apps/web/modules/api/v2/management/responses/route.ts index 3dadae5a75..43961806ec 100644 --- a/apps/web/modules/api/v2/management/responses/route.ts +++ b/apps/web/modules/api/v2/management/responses/route.ts @@ -81,6 +81,6 @@ export const POST = async (request: Request) => return handleApiError(request, createResponseResult.error); } - return responses.successResponse({ data: createResponseResult.data, cors: true }); + return responses.successResponse({ data: createResponseResult.data }); }, }); 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 0c5d5cb3d2..43eb2ce696 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 @@ -1,4 +1,4 @@ -import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; +import { ZWebhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks"; import { makePartialSchema } from "@/modules/api/v2/types/openapi-response"; import { z } from "zod"; @@ -11,7 +11,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = { description: "Gets a webhook from the database.", requestParams: { path: z.object({ - id: webhookIdSchema, + id: ZWebhookIdSchema, }), }, tags: ["Management API > Webhooks"], @@ -34,7 +34,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = { tags: ["Management API > Webhooks"], requestParams: { path: z.object({ - id: webhookIdSchema, + id: ZWebhookIdSchema, }), }, responses: { @@ -56,7 +56,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = { tags: ["Management API > Webhooks"], requestParams: { path: z.object({ - id: webhookIdSchema, + id: ZWebhookIdSchema, }), }, requestBody: { diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts index 858f7fc74c..192685577a 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts @@ -3,7 +3,7 @@ import { mockedPrismaWebhookUpdateReturn, prismaNotFoundError, } from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock"; -import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; +import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { prisma } from "@formbricks/database"; @@ -61,7 +61,7 @@ describe("getWebhook", () => { }); describe("updateWebhook", () => { - const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer; + const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer; test("returns ok on successful update", async () => { vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts index 519cc3a9a7..a11645713e 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts @@ -1,5 +1,5 @@ import { webhookCache } from "@/lib/cache/webhook"; -import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; +import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; import { Webhook } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; @@ -42,7 +42,7 @@ export const getWebhook = async (webhookId: string) => export const updateWebhook = async ( webhookId: string, - webhookInput: z.infer + webhookInput: z.infer ): Promise> => { try { const updatedWebhook = await prisma.webhook.update({ 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 70c810cdf1..1ec1d152b5 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/route.ts @@ -8,8 +8,8 @@ import { updateWebhook, } from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook"; import { - webhookIdSchema, - webhookUpdateSchema, + ZWebhookIdSchema, + ZWebhookUpdateSchema, } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils"; import { NextRequest } from "next/server"; @@ -19,7 +19,7 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ webho authenticatedApiClient({ request, schemas: { - params: z.object({ webhookId: webhookIdSchema }), + params: z.object({ webhookId: ZWebhookIdSchema }), }, externalParams: props.params, handler: async ({ authentication, parsedInput }) => { @@ -53,8 +53,8 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho authenticatedApiClient({ request, schemas: { - params: z.object({ webhookId: webhookIdSchema }), - body: webhookUpdateSchema, + params: z.object({ webhookId: ZWebhookIdSchema }), + body: ZWebhookUpdateSchema, }, externalParams: props.params, handler: async ({ authentication, parsedInput }) => { @@ -112,7 +112,7 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we authenticatedApiClient({ request, schemas: { - params: z.object({ webhookId: webhookIdSchema }), + params: z.object({ webhookId: ZWebhookIdSchema }), }, externalParams: props.params, handler: async ({ authentication, parsedInput }) => { diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts index 9bcc7a708a..82c178a808 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/types/webhooks.ts @@ -4,7 +4,7 @@ import { ZWebhook } from "@formbricks/database/zod/webhooks"; extendZodWithOpenApi(z); -export const webhookIdSchema = z +export const ZWebhookIdSchema = z .string() .cuid2() .openapi({ @@ -16,7 +16,7 @@ export const webhookIdSchema = z }, }); -export const webhookUpdateSchema = ZWebhook.omit({ +export const ZWebhookUpdateSchema = ZWebhook.omit({ id: true, createdAt: true, updatedAt: true, 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 3530e8230c..c60b1d5af6 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/openapi.ts @@ -13,7 +13,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = { summary: "Get webhooks", description: "Gets webhooks from the database.", requestParams: { - query: ZGetWebhooksFilter.sourceType().required(), + query: ZGetWebhooksFilter.sourceType(), }, tags: ["Management API > Webhooks"], responses: { diff --git a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts index 1720fe5018..175c6660b8 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts @@ -13,12 +13,14 @@ export const getWebhooks = async ( params: TGetWebhooksFilter ): Promise, ApiErrorResponseV2>> => { try { + const query = getWebhooksQuery(environmentIds, params); + const [webhooks, count] = await prisma.$transaction([ prisma.webhook.findMany({ - ...getWebhooksQuery(environmentIds, params), + ...query, }), prisma.webhook.count({ - where: getWebhooksQuery(environmentIds, params).where, + where: query.where, }), ]); diff --git a/apps/web/modules/api/v2/me/route.ts b/apps/web/modules/api/v2/me/route.ts index 93878ea6dd..e2d4896820 100644 --- a/apps/web/modules/api/v2/me/route.ts +++ b/apps/web/modules/api/v2/me/route.ts @@ -1,11 +1,20 @@ 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 { NextRequest } from "next/server"; +import { OrganizationAccessType } from "@formbricks/types/api-key"; export const GET = async (request: NextRequest) => authenticatedApiClient({ request, handler: async ({ authentication }) => { + if (!authentication.organizationAccess?.accessControl?.[OrganizationAccessType.Read]) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }); + } + return responses.successResponse({ data: { environmentPermissions: authentication.environmentPermissions.map((permission) => ({ diff --git a/apps/web/modules/api/v2/openapi-document.ts b/apps/web/modules/api/v2/openapi-document.ts index 7e8d805e35..4e2b3173ca 100644 --- a/apps/web/modules/api/v2/openapi-document.ts +++ b/apps/web/modules/api/v2/openapi-document.ts @@ -7,6 +7,7 @@ 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 { userPaths } from "@/modules/api/v2/organizations/[organizationId]/users/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"; @@ -21,6 +22,7 @@ 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 { ZUser } from "@formbricks/database/zod/users"; import { ZWebhook } from "@formbricks/database/zod/webhooks"; extendZodWithOpenApi(z); @@ -44,6 +46,7 @@ const document = createDocument({ ...webhookPaths, ...teamPaths, ...projectTeamPaths, + ...userPaths, }, servers: [ { @@ -92,6 +95,10 @@ const document = createDocument({ name: "Organizations API > Project Teams", description: "Operations for managing project teams.", }, + { + name: "Organizations API > Users", + description: "Operations for managing users.", + }, ], components: { securitySchemes: { @@ -113,6 +120,7 @@ const document = createDocument({ webhook: ZWebhook, team: ZTeam, projectTeam: ZProjectTeam, + user: ZUser, }, }, 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 index 5a8167b6d9..d89131950b 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.test.ts @@ -16,7 +16,9 @@ describe("hasOrganizationIdAndAccess", () => { const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType); expect(result).toBe(false); - expect(spyError).toHaveBeenCalledWith("Organization ID is missing from the authentication object"); + expect(spyError).toHaveBeenCalledWith( + "Organization ID from params does not match the authenticated organization ID" + ); }); it("should return false and log error if param organizationId does not match authentication organizationId", () => { diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts index 59d1016080..b68ac23d28 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/lib/utils.ts @@ -7,12 +7,6 @@ export const hasOrganizationIdAndAccess = ( 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"); 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 index 8a8dc57353..283023aaf6 100644 --- 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 @@ -2,7 +2,6 @@ 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"; @@ -16,7 +15,7 @@ export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = { summary: "Get project teams", description: "Gets projectTeams from the database.", requestParams: { - query: ZGetProjectTeamsFilter.sourceType().required(), + query: ZGetProjectTeamsFilter.sourceType(), path: z.object({ organizationId: ZOrganizationIdSchema, }), @@ -94,7 +93,6 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = { description: "Updates a project team in the database.", tags: ["Organizations API > Project Teams"], requestParams: { - query: ZGetProjectTeamUpdateFilter.required(), path: z.object({ organizationId: ZOrganizationIdSchema, }), @@ -104,7 +102,7 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = { description: "The project team to update", content: { "application/json": { - schema: projectTeamUpdateSchema, + schema: ZProjectTeamInput, }, }, }, 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 index 1a2bcee222..3c06e04237 100644 --- 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 @@ -3,7 +3,7 @@ import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizati import { TGetProjectTeamsFilter, TProjectTeamInput, - projectTeamUpdateSchema, + ZProjectZTeamUpdateSchema, } 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"; @@ -19,12 +19,14 @@ export const getProjectTeams = async ( params: TGetProjectTeamsFilter ): Promise, ApiErrorResponseV2>> => { try { + const query = getProjectTeamsQuery(organizationId, params); + const [projectTeams, count] = await prisma.$transaction([ prisma.projectTeam.findMany({ - ...getProjectTeamsQuery(organizationId, params), + ...query, }), prisma.projectTeam.count({ - where: getProjectTeamsQuery(organizationId, params).where, + where: query.where, }), ]); @@ -67,14 +69,14 @@ export const createProjectTeam = async ( return ok(projectTeam); } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] }); + return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] }); } }; export const updateProjectTeam = async ( teamId: string, projectId: string, - teamInput: z.infer + teamInput: z.infer ): Promise> => { try { const updatedProjectTeam = await prisma.projectTeam.update({ @@ -97,7 +99,7 @@ export const updateProjectTeam = async ( return ok(updatedProjectTeam); } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] }); + return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] }); } }; @@ -125,6 +127,6 @@ export const deleteProjectTeam = async ( return ok(deletedProjectTeam); } catch (error) { - return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] }); + return err({ type: "internal_server_error", details: [{ field: "projectTeam", 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 index 3ced4cf4ba..bf7c7dc4b6 100644 --- 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 @@ -1,7 +1,7 @@ import { TGetProjectTeamsFilter, TProjectTeamInput, - projectTeamUpdateSchema, + ZProjectZTeamUpdateSchema, } from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { TypeOf } from "zod"; @@ -87,7 +87,7 @@ describe("ProjectTeams Lib", () => { permission: "READ", }); const result = await updateProjectTeam("t1", "p1", { permission: "READ" } as unknown as TypeOf< - typeof projectTeamUpdateSchema + typeof ZProjectZTeamUpdateSchema >); expect(result.ok).toBe(true); if (result.ok) { @@ -98,7 +98,7 @@ describe("ProjectTeams Lib", () => { 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 + typeof ZProjectZTeamUpdateSchema >); expect(result.ok).toBe(false); if (!result.ok) { 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 index 07360dba80..15ced42242 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/project-teams/route.ts @@ -16,7 +16,6 @@ import { ZGetProjectTeamUpdateFilter, ZGetProjectTeamsFilter, ZProjectTeamInput, - projectTeamUpdateSchema, } from "./types/project-teams"; export async function GET(request: Request, props: { params: Promise<{ organizationId: string }> }) { @@ -95,7 +94,7 @@ export async function POST(request: Request, props: { params: Promise<{ organiza return handleApiError(request, result.error); } - return responses.successResponse({ data: result.data, cors: true }); + return responses.successResponse({ data: result.data }); }, }); } @@ -104,13 +103,12 @@ export async function PUT(request: Request, props: { params: Promise<{ organizat return authenticatedApiClient({ request, schemas: { - body: projectTeamUpdateSchema, - query: ZGetProjectTeamUpdateFilter, + body: ZProjectTeamInput, params: z.object({ organizationId: ZOrganizationIdSchema }), }, externalParams: props.params, - handler: async ({ parsedInput: { query, body, params }, authentication }) => { - const { teamId, projectId } = query!; + handler: async ({ parsedInput: { body, params }, authentication }) => { + const { teamId, projectId } = body!; if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { return handleApiError(request, { @@ -130,7 +128,7 @@ export async function PUT(request: Request, props: { params: Promise<{ organizat return handleApiError(request, result.error); } - return responses.successResponse({ data: result.data, cors: true }); + return responses.successResponse({ data: result.data }); }, }); } @@ -164,7 +162,7 @@ export async function DELETE(request: Request, props: { params: Promise<{ organi return handleApiError(request, result.error); } - return responses.successResponse({ data: result.data, cors: true }); + return responses.successResponse({ data: result.data }); }, }); } 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 index 11bf7c4580..d852852bd7 100644 --- 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 @@ -32,6 +32,6 @@ export const ZGetProjectTeamUpdateFilter = z.object({ projectId: z.string().cuid2(), }); -export const projectTeamUpdateSchema = ZProjectTeam.pick({ +export const ZProjectZTeamUpdateSchema = ZProjectTeam.pick({ permission: 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 index 443d71ea98..f92910e592 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/openapi.ts @@ -22,7 +22,7 @@ export const getTeamsEndpoint: ZodOpenApiOperationObject = { path: z.object({ organizationId: ZOrganizationIdSchema, }), - query: ZGetTeamsFilter.sourceType().required(), + query: ZGetTeamsFilter.sourceType(), }, tags: ["Organizations API > Teams"], responses: { 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 index db5812b3ef..c6653cdf84 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/lib/teams.ts @@ -48,12 +48,14 @@ export const getTeams = async ( params: TGetTeamsFilter ): Promise, ApiErrorResponseV2>> => { try { + const query = getTeamsQuery(organizationId, params); + const [teams, count] = await prisma.$transaction([ prisma.team.findMany({ - ...getTeamsQuery(organizationId, params), + ...query, }), prisma.team.count({ - where: getTeamsQuery(organizationId, params).where, + where: query.where, }), ]); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts index bf185277ac..44b61a41bf 100644 --- a/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts +++ b/apps/web/modules/api/v2/organizations/[organizationId]/teams/route.ts @@ -59,6 +59,6 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza return handleApiError(request, createTeamResult.error); } - return responses.successResponse({ data: createTeamResult.data, cors: true }); + return responses.successResponse({ data: createTeamResult.data }); }, }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/openapi.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/openapi.ts new file mode 100644 index 0000000000..1289dcf996 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/openapi.ts @@ -0,0 +1,105 @@ +import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations"; +import { + ZGetUsersFilter, + ZUserInput, + ZUserInputPatch, +} from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; +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 { ZUser } from "@formbricks/database/zod/users"; + +export const getUsersEndpoint: ZodOpenApiOperationObject = { + operationId: "getUsers", + summary: "Get users", + description: `Gets users from the database.
Only available for self-hosted Formbricks.`, + requestParams: { + path: z.object({ + organizationId: ZOrganizationIdSchema, + }), + query: ZGetUsersFilter.sourceType(), + }, + tags: ["Organizations API > Users"], + responses: { + "200": { + description: "Users retrieved successfully.", + content: { + "application/json": { + schema: responseWithMetaSchema(makePartialSchema(ZUser)), + }, + }, + }, + }, +}; + +export const createUserEndpoint: ZodOpenApiOperationObject = { + operationId: "createUser", + summary: "Create a user", + description: `Create a new user in the database.
Only available for self-hosted Formbricks.`, + requestParams: { + path: z.object({ + organizationId: ZOrganizationIdSchema, + }), + }, + tags: ["Organizations API > Users"], + requestBody: { + required: true, + description: "The user to create", + content: { + "application/json": { + schema: ZUserInput, + }, + }, + }, + responses: { + "201": { + description: "User created successfully.", + content: { + "application/json": { + schema: makePartialSchema(ZUser), + }, + }, + }, + }, +}; + +export const updateUserEndpoint: ZodOpenApiOperationObject = { + operationId: "updateUser", + summary: "Update a user", + description: `Updates an existing user in the database.
Only available for self-hosted Formbricks.`, + requestParams: { + path: z.object({ + organizationId: ZOrganizationIdSchema, + }), + }, + tags: ["Organizations API > Users"], + requestBody: { + required: true, + description: "The user to update", + content: { + "application/json": { + schema: ZUserInputPatch, + }, + }, + }, + responses: { + "200": { + description: "User updated successfully.", + content: { + "application/json": { + schema: makePartialSchema(ZUser), + }, + }, + }, + }, +}; + +export const userPaths: ZodOpenApiPathsObject = { + "/{organizationId}/users": { + servers: organizationServer, + get: getUsersEndpoint, + post: createUserEndpoint, + patch: updateUserEndpoint, + }, +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts new file mode 100644 index 0000000000..c94fc944ed --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/users.test.ts @@ -0,0 +1,195 @@ +import { teamCache } from "@/lib/cache/team"; +import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; +import { describe, expect, it, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { membershipCache } from "@formbricks/lib/membership/cache"; +import { userCache } from "@formbricks/lib/user/cache"; +import { createUser, getUsers, updateUser } from "../users"; + +const mockUser = { + id: "user123", + email: "test@example.com", + name: "Test User", + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + isActive: true, + role: "admin", + memberships: [{ organizationId: "org456", role: "admin" }], + teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }], +}; + +vi.mock("@formbricks/database", () => ({ + prisma: { + user: { + findMany: vi.fn(), + count: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + team: { + findMany: vi.fn(), + }, + teamUser: { + create: vi.fn(), + delete: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +vi.spyOn(membershipCache, "revalidate").mockImplementation(() => {}); +vi.spyOn(userCache, "revalidate").mockImplementation(() => {}); +vi.spyOn(teamCache, "revalidate").mockImplementation(() => {}); + +describe("Users Lib", () => { + describe("getUsers", () => { + it("returns users with meta on success", async () => { + const usersArray = [mockUser]; + (prisma.$transaction as any).mockResolvedValueOnce([usersArray, usersArray.length]); + const result = await getUsers("org456", { limit: 10, skip: 0 } as TGetUsersFilter); + expect(prisma.$transaction).toHaveBeenCalled(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.data).toStrictEqual([ + { + id: mockUser.id, + email: mockUser.email, + name: mockUser.name, + lastLoginAt: expect.any(Date), + isActive: true, + role: mockUser.role, + teams: ["Test Team"], + createdAt: expect.any(Date), + updatedAt: expect.any(Date), + }, + ]); + } + }); + + it("returns internal_server_error if prisma fails", async () => { + (prisma.$transaction as any).mockRejectedValueOnce(new Error("Transaction error")); + const result = await getUsers("org456", { limit: 10, skip: 0 } as TGetUsersFilter); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe("internal_server_error"); + } + }); + }); + + describe("createUser", () => { + it("creates user and revalidates caches", async () => { + (prisma.user.create as any).mockResolvedValueOnce(mockUser); + const result = await createUser( + { name: "Test User", email: "test@example.com", role: "member" }, + "org456" + ); + expect(prisma.user.create).toHaveBeenCalled(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.id).toBe(mockUser.id); + } + }); + + it("returns internal_server_error if creation fails", async () => { + (prisma.user.create as any).mockRejectedValueOnce(new Error("Create error")); + const result = await createUser({ name: "fail", email: "fail@example.com", role: "manager" }, "org456"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe("internal_server_error"); + } + }); + }); + + describe("updateUser", () => { + it("updates user and revalidates caches", async () => { + (prisma.user.findUnique as any).mockResolvedValueOnce(mockUser); + (prisma.$transaction as any).mockResolvedValueOnce([{ ...mockUser, name: "Updated User" }]); + const result = await updateUser({ email: mockUser.email, name: "Updated User" }, "org456"); + expect(prisma.user.findUnique).toHaveBeenCalled(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.name).toBe("Updated User"); + } + }); + + it("returns not_found if user doesn't exist", async () => { + (prisma.user.findUnique as any).mockResolvedValueOnce(null); + const result = await updateUser({ email: "unknown@example.com" }, "org456"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe("not_found"); + } + }); + + it("returns internal_server_error if update fails", async () => { + (prisma.user.findUnique as any).mockResolvedValueOnce(mockUser); + (prisma.$transaction as any).mockRejectedValueOnce(new Error("Update error")); + const result = await updateUser({ email: mockUser.email }, "org456"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.type).toBe("internal_server_error"); + } + }); + }); + + describe("createUser with teams", () => { + it("creates user with existing teams", async () => { + (prisma.team.findMany as any).mockResolvedValueOnce([ + { id: "team123", name: "MyTeam", projectTeams: [{ projectId: "proj789" }] }, + ]); + (prisma.user.create as any).mockResolvedValueOnce({ + ...mockUser, + teamUsers: [{ team: { id: "team123", name: "MyTeam" } }], + }); + + const result = await createUser( + { name: "Test", email: "team@example.com", role: "manager", teams: ["MyTeam"], isActive: true }, + "org456" + ); + + expect(prisma.user.create).toHaveBeenCalled(); + expect(teamCache.revalidate).toHaveBeenCalled(); + expect(membershipCache.revalidate).toHaveBeenCalled(); + expect(result.ok).toBe(true); + }); + }); + + describe("updateUser with team changes", () => { + it("removes a team and adds new team", async () => { + (prisma.user.findUnique as any).mockResolvedValueOnce({ + ...mockUser, + teamUsers: [{ team: { id: "team123", name: "OldTeam", projectTeams: [{ projectId: "proj789" }] } }], + }); + (prisma.team.findMany as any).mockResolvedValueOnce([ + { id: "team456", name: "NewTeam", projectTeams: [] }, + ]); + (prisma.$transaction as any).mockResolvedValueOnce([ + // deleted OldTeam from user + { team: { id: "team123", name: "OldTeam", projectTeams: [{ projectId: "proj789" }] } }, + // created teamUsers for NewTeam + { + team: { id: "team456", name: "NewTeam", projectTeams: [] }, + }, + // updated user + { ...mockUser, name: "Updated Name" }, + ]); + + const result = await updateUser( + { email: mockUser.email, name: "Updated Name", teams: ["NewTeam"] }, + "org456" + ); + + expect(prisma.user.findUnique).toHaveBeenCalled(); + expect(teamCache.revalidate).toHaveBeenCalledTimes(3); + expect(membershipCache.revalidate).toHaveBeenCalled(); + expect(userCache.revalidate).toHaveBeenCalled(); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.data.teams).toContain("NewTeam"); + expect(result.data.name).toBe("Updated Name"); + } + }); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/utils.test.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/utils.test.ts new file mode 100644 index 0000000000..dd3cb07a2c --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/tests/utils.test.ts @@ -0,0 +1,45 @@ +import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; +import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; +import { describe, expect, it, vi } from "vitest"; +import { getUsersQuery } from "../utils"; + +vi.mock("@/modules/api/v2/management/lib/utils", () => ({ + pickCommonFilter: vi.fn(), + buildCommonFilterQuery: vi.fn(), +})); + +describe("getUsersQuery", () => { + it("returns default query if no params are provided", () => { + const result = getUsersQuery("org123"); + expect(result).toEqual({ + where: { + memberships: { + some: { + organizationId: "org123", + }, + }, + }, + }); + }); + + it("includes email filter if email param is provided", () => { + const result = getUsersQuery("org123", { email: "test@example.com" } as TGetUsersFilter); + expect(result.where?.email).toEqual({ + contains: "test@example.com", + mode: "insensitive", + }); + }); + + it("includes id filter if id param is provided", () => { + const result = getUsersQuery("org123", { id: "user123" } as TGetUsersFilter); + expect(result.where?.id).toBe("user123"); + }); + + it("applies baseFilter if pickCommonFilter returns something", () => { + vi.mocked(pickCommonFilter).mockReturnValueOnce({ someField: "test" } as unknown as ReturnType< + typeof pickCommonFilter + >); + getUsersQuery("org123", {} as TGetUsersFilter); + expect(buildCommonFilterQuery).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts new file mode 100644 index 0000000000..85b7aac577 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/users.ts @@ -0,0 +1,387 @@ +import { teamCache } from "@/lib/cache/team"; +import { getUsersQuery } from "@/modules/api/v2/organizations/[organizationId]/users/lib/utils"; +import { + TGetUsersFilter, + TUserInput, + TUserInputPatch, +} from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; +import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success"; +import { OrganizationRole, Prisma, TeamUserRole } from "@prisma/client"; +import { prisma } from "@formbricks/database"; +import { TUser } from "@formbricks/database/zod/users"; +import { membershipCache } from "@formbricks/lib/membership/cache"; +import { captureTelemetry } from "@formbricks/lib/telemetry"; +import { userCache } from "@formbricks/lib/user/cache"; +import { Result, err, ok } from "@formbricks/types/error-handlers"; + +export const getUsers = async ( + organizationId: string, + params: TGetUsersFilter +): Promise, ApiErrorResponseV2>> => { + try { + const query = getUsersQuery(organizationId, params); + + const [users, count] = await prisma.$transaction([ + prisma.user.findMany({ + ...query, + include: { + teamUsers: { + include: { + team: true, + }, + }, + memberships: { + select: { + role: true, + organizationId: true, + }, + }, + }, + }), + prisma.user.count({ + where: query.where, + }), + ]); + + const returnedUsers = users.map( + (user) => + ({ + id: user.id, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + email: user.email, + name: user.name, + lastLoginAt: user.lastLoginAt, + isActive: user.isActive, + role: user.memberships.filter((membership) => membership.organizationId === organizationId)[0].role, + teams: user.teamUsers.map((teamUser) => teamUser.team.name), + }) as TUser + ); + + return ok({ + data: returnedUsers, + meta: { + total: count, + limit: params.limit, + offset: params.skip, + }, + }); + } catch (error) { + return err({ type: "internal_server_error", details: [{ field: "users", issue: error.message }] }); + } +}; + +export const createUser = async ( + userInput: TUserInput, + organizationId +): Promise> => { + captureTelemetry("user created"); + + const { name, email, role, teams, isActive } = userInput; + + try { + const existingTeams = teams && (await getExistingTeamsFromInput(teams, organizationId)); + + let teamUsersToCreate; + + if (existingTeams) { + teamUsersToCreate = existingTeams.map((team) => ({ + role: TeamUserRole.contributor, + team: { + connect: { + id: team.id, + }, + }, + })); + } + + const prismaData: Prisma.UserCreateInput = { + name, + email, + isActive: isActive, + memberships: { + create: { + accepted: true, // auto accept because there is no invite + role: role.toLowerCase() as OrganizationRole, + organization: { + connect: { + id: organizationId, + }, + }, + }, + }, + teamUsers: + existingTeams?.length > 0 + ? { + create: teamUsersToCreate, + } + : undefined, + }; + + const user = await prisma.user.create({ + data: prismaData, + include: { + memberships: { + select: { + role: true, + organizationId: true, + }, + }, + }, + }); + + existingTeams?.forEach((team) => { + teamCache.revalidate({ + id: team.id, + organizationId: organizationId, + }); + + for (const projectTeam of team.projectTeams) { + teamCache.revalidate({ + projectId: projectTeam.projectId, + }); + } + }); + + // revalidate membership cache + membershipCache.revalidate({ + organizationId: organizationId, + userId: user.id, + }); + + const returnedUser = { + id: user.id, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + email: user.email, + name: user.name, + lastLoginAt: user.lastLoginAt, + isActive: user.isActive, + role: user.memberships.filter((membership) => membership.organizationId === organizationId)[0].role, + teams: existingTeams ? existingTeams.map((team) => team.name) : [], + } as TUser; + + return ok(returnedUser); + } catch (error) { + return err({ type: "internal_server_error", details: [{ field: "user", issue: error.message }] }); + } +}; + +export const updateUser = async ( + userInput: TUserInputPatch, + organizationId: string +): Promise> => { + captureTelemetry("user updated"); + + const { name, email, role, teams, isActive } = userInput; + let existingTeams: string[] = []; + let newTeams; + + try { + // First, fetch the existing user along with memberships and teamUsers. + const existingUser = await prisma.user.findUnique({ + where: { email }, + include: { + memberships: { + select: { + role: true, + organizationId: true, + }, + }, + teamUsers: { + include: { + team: true, + }, + }, + }, + }); + + if (!existingUser) { + return err({ + type: "not_found", + details: [{ field: "user", issue: "not found" }], + }); + } + + // Capture the existing team names for the user. + existingTeams = existingUser.teamUsers.map((teamUser) => teamUser.team.name); + + // Build an array of operations for deleting teamUsers that are not in the input. + const deleteTeamOps = [] as Prisma.PrismaPromise[]; + existingUser.teamUsers.forEach((teamUser) => { + if (teams && !teams?.includes(teamUser.team.name)) { + deleteTeamOps.push( + prisma.teamUser.delete({ + where: { + teamId_userId: { + teamId: teamUser.team.id, + userId: existingUser.id, + }, + }, + include: { + team: { + include: { + projectTeams: { + select: { projectId: true }, + }, + }, + }, + }, + }) + ); + } + }); + + // Look up teams from the input that exist in this organization. + newTeams = await getExistingTeamsFromInput(teams, organizationId); + const existingUserTeamNames = existingUser.teamUsers.map((teamUser) => teamUser.team.name); + + // Build an array of operations for creating new teamUsers. + const createTeamOps = [] as Prisma.PrismaPromise[]; + newTeams?.forEach((team) => { + if (!existingUserTeamNames.includes(team.name)) { + createTeamOps.push( + prisma.teamUser.create({ + data: { + role: TeamUserRole.contributor, + user: { connect: { id: existingUser.id } }, + team: { connect: { id: team.id } }, + }, + include: { + team: { + include: { + projectTeams: { + select: { projectId: true }, + }, + }, + }, + }, + }) + ); + } + }); + + const prismaData: Prisma.UserUpdateInput = { + name: name ?? undefined, + email: email ?? undefined, + isActive: isActive ?? undefined, + memberships: { + updateMany: { + where: { + organizationId, + }, + data: { + role: role ? (role.toLowerCase() as OrganizationRole) : undefined, + }, + }, + }, + }; + + // Build the user update operation. + const updateUserOp = prisma.user.update({ + where: { email }, + data: prismaData, + include: { + memberships: { + select: { role: true, organizationId: true }, + }, + }, + }); + + // Combine all operations into one transaction. + const operations = [...deleteTeamOps, ...createTeamOps, updateUserOp]; + + // Execute the transaction. The result will be an array with the results in the same order. + const results = await prisma.$transaction(operations); + + // Retrieve the updated user result. Since the update was the last operation, it is the last item. + const updatedUser = results[results.length - 1]; + + // For each deletion, revalidate the corresponding team and its project caches. + for (const opResult of results.slice(0, deleteTeamOps.length)) { + const deletedTeamUser = opResult; + teamCache.revalidate({ + id: deletedTeamUser.team.id, + userId: existingUser.id, + organizationId, + }); + + deletedTeamUser.team.projectTeams.forEach((projectTeam) => { + teamCache.revalidate({ + projectId: projectTeam.projectId, + }); + }); + } + // For each creation, do the same. + for (const opResult of results.slice(deleteTeamOps.length, deleteTeamOps.length + createTeamOps.length)) { + const newTeamUser = opResult; + teamCache.revalidate({ + id: newTeamUser.team.id, + userId: existingUser.id, + organizationId, + }); + + newTeamUser.team.projectTeams.forEach((projectTeam) => { + teamCache.revalidate({ + projectId: projectTeam.projectId, + }); + }); + } + + // Revalidate membership and user caches for the updated user. + membershipCache.revalidate({ + organizationId, + userId: updatedUser.id, + }); + userCache.revalidate({ + id: updatedUser.id, + email: updatedUser.email, + }); + + const returnedUser = { + id: updatedUser.id, + createdAt: updatedUser.createdAt, + updatedAt: updatedUser.updatedAt, + email: updatedUser.email, + name: updatedUser.name, + lastLoginAt: updatedUser.lastLoginAt, + isActive: updatedUser.isActive, + role: updatedUser.memberships.find( + (m: { organizationId: string }) => m.organizationId === organizationId + )?.role, + teams: newTeams ? newTeams.map((team) => team.name) : existingTeams, + }; + + return ok(returnedUser); + } catch (error) { + return err({ + type: "internal_server_error", + details: [{ field: "user", issue: error.message }], + }); + } +}; + +const getExistingTeamsFromInput = async (userInputTeams: string[] | undefined, organizationId: string) => { + let existingTeams; + + if (userInputTeams) { + existingTeams = await prisma.team.findMany({ + where: { + name: { in: userInputTeams }, + organizationId, + }, + select: { + id: true, + name: true, + projectTeams: { + select: { + projectId: true, + }, + }, + }, + }); + } + + return existingTeams; +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/utils.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/utils.ts new file mode 100644 index 0000000000..f27ece8677 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/lib/utils.ts @@ -0,0 +1,42 @@ +import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils"; +import { TGetUsersFilter } from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; +import { Prisma } from "@prisma/client"; + +export const getUsersQuery = (organizationId: string, params?: TGetUsersFilter) => { + let query: Prisma.UserFindManyArgs = { + where: { + memberships: { + some: { + organizationId, + }, + }, + }, + }; + + if (!params) return query; + + if (params.email) { + query.where = { + ...query.where, + email: { + contains: params.email, + mode: "insensitive", + }, + }; + } + + if (params.id) { + query.where = { + ...query.where, + id: params.id, + }; + } + + const baseFilter = pickCommonFilter(params); + + if (baseFilter) { + query = buildCommonFilterQuery(query, baseFilter); + } + + return query; +}; diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts new file mode 100644 index 0000000000..7097d2d56d --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/route.ts @@ -0,0 +1,123 @@ +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 { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations"; +import { + createUser, + getUsers, + updateUser, +} from "@/modules/api/v2/organizations/[organizationId]/users/lib/users"; +import { + ZGetUsersFilter, + ZUserInput, + ZUserInputPatch, +} from "@/modules/api/v2/organizations/[organizationId]/users/types/users"; +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants"; +import { OrganizationAccessType } from "@formbricks/types/api-key"; + +export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) => + authenticatedApiClient({ + request, + schemas: { + query: ZGetUsersFilter.sourceType(), + params: z.object({ organizationId: ZOrganizationIdSchema }), + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput: { query, params } }) => { + if (IS_FORMBRICKS_CLOUD) { + return handleApiError(request, { + type: "bad_request", + details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }], + }); + } + + if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }); + } + + const res = await getUsers(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: ZUserInput, + params: z.object({ organizationId: ZOrganizationIdSchema }), + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput: { body, params } }) => { + if (IS_FORMBRICKS_CLOUD) { + return handleApiError(request, { + type: "bad_request", + details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }], + }); + } + + if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }); + } + + const createUserResult = await createUser(body!, authentication.organizationId); + if (!createUserResult.ok) { + return handleApiError(request, createUserResult.error); + } + + return responses.successResponse({ data: createUserResult.data }); + }, + }); + +export const PATCH = async (request: Request, props: { params: Promise<{ organizationId: string }> }) => + authenticatedApiClient({ + request, + schemas: { + body: ZUserInputPatch, + params: z.object({ organizationId: ZOrganizationIdSchema }), + }, + externalParams: props.params, + handler: async ({ authentication, parsedInput: { body, params } }) => { + if (IS_FORMBRICKS_CLOUD) { + return handleApiError(request, { + type: "bad_request", + details: [{ field: "organizationId", issue: "This endpoint is not supported on Formbricks Cloud" }], + }); + } + + if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) { + return handleApiError(request, { + type: "unauthorized", + details: [{ field: "organizationId", issue: "unauthorized" }], + }); + } + + if (!body?.email) { + return handleApiError(request, { + type: "bad_request", + details: [{ field: "email", issue: "Email is required" }], + }); + } + + const updateUserResult = await updateUser(body, authentication.organizationId); + if (!updateUserResult.ok) { + return handleApiError(request, updateUserResult.error); + } + + return responses.successResponse({ data: updateUserResult.data }); + }, + }); diff --git a/apps/web/modules/api/v2/organizations/[organizationId]/users/types/users.ts b/apps/web/modules/api/v2/organizations/[organizationId]/users/types/users.ts new file mode 100644 index 0000000000..17458716e6 --- /dev/null +++ b/apps/web/modules/api/v2/organizations/[organizationId]/users/types/users.ts @@ -0,0 +1,42 @@ +import { ZGetFilter } from "@/modules/api/v2/types/api-filter"; +import { z } from "zod"; +import { ZUser } from "@formbricks/database/zod/users"; +import { ZUserName } from "@formbricks/types/user"; + +export const ZGetUsersFilter = ZGetFilter.extend({ + id: z.string().optional(), + email: z.string().optional(), +}).refine( + (data) => { + if (data.startDate && data.endDate && data.startDate > data.endDate) { + return false; + } + return true; + }, + { + message: "startDate must be before endDate", + } +); + +export type TGetUsersFilter = z.infer; + +export const ZUserInput = ZUser.omit({ + id: true, + createdAt: true, + updatedAt: true, + lastLoginAt: true, +}).extend({ + isActive: ZUser.shape.isActive.optional(), +}); + +export type TUserInput = z.infer; + +export const ZUserInputPatch = ZUserInput.extend({ + // Override specific fields to be optional + name: ZUserName.optional(), + role: ZUser.shape.role.optional(), + teams: ZUser.shape.teams.optional(), + isActive: ZUser.shape.isActive.optional(), +}); + +export type TUserInputPatch = z.infer; diff --git a/apps/web/playwright/api/constants.ts b/apps/web/playwright/api/constants.ts index f46f162295..75f7d4de56 100644 --- a/apps/web/playwright/api/constants.ts +++ b/apps/web/playwright/api/constants.ts @@ -7,3 +7,4 @@ 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`; +export const USERS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/users`; diff --git a/apps/web/playwright/api/organization/project-team.spec.ts b/apps/web/playwright/api/organization/project-team.spec.ts index ebf5ce6043..2aeb919743 100644 --- a/apps/web/playwright/api/organization/project-team.spec.ts +++ b/apps/web/playwright/api/organization/project-team.spec.ts @@ -90,15 +90,15 @@ test.describe("API Tests for ProjectTeams", () => { await test.step("Update ProjectTeam by ID via API", async () => { const body = { permission: "read", + teamId: teamId, + projectId: projectId, }; - 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); diff --git a/apps/web/playwright/api/organization/user.spec.ts b/apps/web/playwright/api/organization/user.spec.ts new file mode 100644 index 0000000000..e81858c1fa --- /dev/null +++ b/apps/web/playwright/api/organization/user.spec.ts @@ -0,0 +1,131 @@ +import { ME_API_URL, TEAMS_API_URL, USERS_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 Users", () => { + test("Create, Retrieve, Filter, and Update Users via API", async ({ page, users, request }) => { + let apiKey; + let organizationId: string; + let createdUserId: string; + let teamName = "New Team from API"; + + const randomSuffix = Math.random().toString(36).substring(2, 15); + const userEmail = `usere2etest${randomSuffix}@formbricks-test.com`; + + try { + ({ apiKey } = await loginAndGetApiKey(page, users)); + } catch (error) { + logger.error(error, "Error during login and getting API key"); + throw error; + } + + 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?.organizationId).toBeTruthy(); + organizationId = responseBody.data.organizationId; + }); + + // Create a team to use for the project team + await test.step("Create Team via API", async () => { + const teamBody = { + organizationId: organizationId, + name: teamName, + }; + + 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(teamName); + }); + + await test.step("Create User via API", async () => { + const userData = { + name: "E2E Test User", + email: userEmail, + role: "manager", + isActive: true, + teams: [teamName], + }; + + const response = await request.post(USERS_API_URL(organizationId), { + headers: { + "x-api-key": apiKey, + "Content-Type": "application/json", + }, + data: userData, + }); + + expect(response.ok()).toBe(true); + const responseBody = await response.json(); + expect(responseBody.data.name).toEqual("E2E Test User"); + createdUserId = responseBody.data.id; + }); + + await test.step("Retrieve All Users via API", async () => { + const response = await request.get(USERS_API_URL(organizationId), { + headers: { "x-api-key": apiKey }, + }); + expect(response.ok()).toBe(true); + const responseBody = await response.json(); + expect(Array.isArray(responseBody.data)).toBe(true); + expect(responseBody.data.some((user: any) => user.id === createdUserId)).toBe(true); + }); + + await test.step("Filter Users by Email via API", async () => { + const queryParams = { email: userEmail }; + const response = await request.get(USERS_API_URL(organizationId), { + headers: { "x-api-key": apiKey }, + params: queryParams, + }); + expect(response.ok()).toBe(true); + const responseBody = await response.json(); + + expect(responseBody.data.length).toBeGreaterThan(0); + expect(responseBody.data[0].email).toBe(userEmail); + }); + + await test.step("Partially Update User via PATCH", async () => { + const patchData = { email: userEmail, name: "Updated E2E Name" }; + const response = await request.patch(USERS_API_URL(organizationId), { + headers: { "x-api-key": apiKey, "Content-Type": "application/json" }, + data: patchData, + }); + expect(response.ok()).toBe(true); + const responseBody = await response.json(); + expect(responseBody.data.name).toBe("Updated E2E Name"); + }); + + await test.step("Fully Update User via PATCH", async () => { + const patchData = { + email: userEmail, + name: "Fully Updated E2E", + role: "member", + teams: [], + isActive: false, + }; + const response = await request.patch(USERS_API_URL(organizationId), { + headers: { "x-api-key": apiKey, "Content-Type": "application/json" }, + data: patchData, + }); + expect(response.ok()).toBe(true); + const responseBody = await response.json(); + expect(responseBody.data.name).toBe("Fully Updated E2E"); + expect(responseBody.data.role).toBe("member"); + expect(responseBody.data.isActive).toBe(false); + expect(responseBody.data.teams).toEqual([]); + }); + }); +}); diff --git a/docs/api-v2-reference/openapi.yml b/docs/api-v2-reference/openapi.yml index e448d7f68a..af3f71f47a 100644 --- a/docs/api-v2-reference/openapi.yml +++ b/docs/api-v2-reference/openapi.yml @@ -27,6 +27,8 @@ tags: description: Operations for managing teams. - name: Organizations API > Project Teams description: Operations for managing project teams. + - name: Organizations API > Users + description: Operations for managing users. security: - apiKeyAuth: [] paths: @@ -547,7 +549,15 @@ paths: data: type: array items: - type: string + anyOf: + - type: string + const: owner + - type: string + const: manager + - type: string + const: member + - type: string + const: billing /me: get: operationId: me @@ -650,22 +660,18 @@ paths: name: startDate schema: type: string - required: true - in: query name: endDate schema: type: string - required: true - in: query name: surveyId schema: type: string - required: true - in: query name: contactId schema: type: string - required: true responses: "200": description: Responses retrieved successfully. @@ -2264,19 +2270,16 @@ paths: name: startDate schema: type: string - required: true - in: query name: endDate schema: type: string - required: true - in: query name: surveyIds schema: type: array items: type: string - required: true responses: "200": description: Webhooks retrieved successfully. @@ -2704,12 +2707,10 @@ paths: name: startDate schema: type: string - required: true - in: query name: endDate schema: type: string - required: true responses: "200": description: Teams retrieved successfully. @@ -2998,22 +2999,18 @@ paths: 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. @@ -3137,16 +3134,6 @@ paths: 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 @@ -3155,6 +3142,12 @@ paths: 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: @@ -3163,6 +3156,8 @@ paths: - manage description: Level of access granted to the project required: + - teamId + - projectId - permission responses: "200": @@ -3245,6 +3240,334 @@ paths: - readWrite - manage description: Level of access granted to the project + /{organizationId}/users: + servers: *a10 + get: + operationId: getUsers + summary: Get users + description: Gets users from the database.
Only available for self-hosted + Formbricks. + tags: + - Organizations API > Users + 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 + - in: query + name: endDate + schema: + type: string + - in: query + name: id + schema: + type: string + - in: query + name: email + schema: + type: string + responses: + "200": + description: Users retrieved successfully. + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + type: object + properties: + id: + type: string + description: The ID of the user + createdAt: + type: string + description: The date and time the user was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the user was last updated + example: 2021-01-01T00:00:00.000Z + lastLoginAt: + type: string + description: The date and time the user last logged in + example: 2021-01-01T00:00:00.000Z + isActive: + type: boolean + description: Whether the user is active + example: true + name: + type: string + pattern: ^[\p{L}\p{M}\s'\d-]+$ + minLength: 1 + description: The name of the user + example: John Doe + email: + type: string + format: email + maxLength: 255 + description: The email of the user + example: example@example.com + role: + type: string + enum: &a11 + - owner + - manager + - member + description: The role of the user in the organization + example: member + teams: + type: array + items: + type: string + description: The list of teams the user is a member of + example: &a12 + - team1 + - team2 + meta: + type: object + properties: + total: + type: number + limit: + type: number + offset: + type: number + post: + operationId: createUser + summary: Create a user + description: Create a new user in the database.
Only available for + self-hosted Formbricks. + tags: + - Organizations API > Users + parameters: + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + requestBody: + required: true + description: The user to create + content: + application/json: + schema: + type: object + properties: + isActive: + type: boolean + description: Whether the user is active + example: true + name: + type: string + pattern: ^[\p{L}\p{M}\s'\d-]+$ + minLength: 1 + description: The name of the user + example: John Doe + email: + type: string + format: email + maxLength: 255 + description: The email of the user + example: example@example.com + role: + type: string + enum: *a11 + description: The role of the user in the organization + example: member + teams: + type: array + items: + type: string + description: The list of teams the user is a member of + example: *a12 + required: + - name + - email + - role + responses: + "201": + description: User created successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the user + createdAt: + type: string + description: The date and time the user was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the user was last updated + example: 2021-01-01T00:00:00.000Z + lastLoginAt: + type: string + description: The date and time the user last logged in + example: 2021-01-01T00:00:00.000Z + isActive: + type: boolean + description: Whether the user is active + example: true + name: + type: string + pattern: ^[\p{L}\p{M}\s'\d-]+$ + minLength: 1 + description: The name of the user + example: John Doe + email: + type: string + format: email + maxLength: 255 + description: The email of the user + example: example@example.com + role: + type: string + enum: *a11 + description: The role of the user in the organization + example: member + teams: + type: array + items: + type: string + description: The list of teams the user is a member of + example: *a12 + patch: + operationId: updateUser + summary: Update a user + description: Updates an existing user in the database.
Only available for + self-hosted Formbricks. + tags: + - Organizations API > Users + parameters: + - in: path + name: organizationId + description: The ID of the organization + schema: + $ref: "#/components/schemas/organizationId" + required: true + requestBody: + required: true + description: The user to update + content: + application/json: + schema: + type: object + properties: + isActive: + type: boolean + description: Whether the user is active + example: true + name: + type: string + pattern: ^[\p{L}\p{M}\s'\d-]+$ + minLength: 1 + email: + type: string + format: email + maxLength: 255 + description: The email of the user + example: example@example.com + role: + type: string + enum: *a11 + description: The role of the user in the organization + example: member + teams: + type: array + items: + type: string + description: The list of teams the user is a member of + example: *a12 + required: + - email + responses: + "200": + description: User updated successfully. + content: + application/json: + schema: + type: object + properties: + id: + type: string + description: The ID of the user + createdAt: + type: string + description: The date and time the user was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the user was last updated + example: 2021-01-01T00:00:00.000Z + lastLoginAt: + type: string + description: The date and time the user last logged in + example: 2021-01-01T00:00:00.000Z + isActive: + type: boolean + description: Whether the user is active + example: true + name: + type: string + pattern: ^[\p{L}\p{M}\s'\d-]+$ + minLength: 1 + description: The name of the user + example: John Doe + email: + type: string + format: email + maxLength: 255 + description: The email of the user + example: example@example.com + role: + type: string + enum: *a11 + description: The role of the user in the organization + example: member + teams: + type: array + items: + type: string + description: The list of teams the user is a member of + example: *a12 components: securitySchemes: apiKeyAuth: @@ -3259,7 +3582,15 @@ components: data: type: array items: - type: string + anyOf: + - type: string + const: owner + - type: string + const: manager + - type: string + const: member + - type: string + const: billing required: - data me: @@ -3708,7 +4039,7 @@ components: required: - id - type - default: &a12 [] + default: &a14 [] description: The endings of the survey thankYouCard: type: @@ -3776,7 +4107,7 @@ components: description: Survey variables displayOption: type: string - enum: &a13 + enum: &a15 - displayOnce - displayMultiple - displaySome @@ -4011,13 +4342,13 @@ components: properties: linkSurveys: type: string - enum: &a11 + enum: &a13 - casual - straight - simple appSurveys: type: string - enum: *a11 + enum: *a13 required: - linkSurveys - appSurveys @@ -4248,6 +4579,60 @@ components: - projectId - teamId - permission + user: + type: object + properties: + id: + type: string + description: The ID of the user + createdAt: + type: string + description: The date and time the user was created + example: 2021-01-01T00:00:00.000Z + updatedAt: + type: string + description: The date and time the user was last updated + example: 2021-01-01T00:00:00.000Z + lastLoginAt: + type: string + description: The date and time the user last logged in + example: 2021-01-01T00:00:00.000Z + isActive: + type: boolean + description: Whether the user is active + example: true + name: + type: string + pattern: ^[\p{L}\p{M}\s'\d-]+$ + minLength: 1 + description: The name of the user + example: John Doe + email: + type: string + format: email + maxLength: 255 + description: The email of the user + example: example@example.com + role: + type: string + enum: *a11 + description: The role of the user in the organization + example: member + teams: + type: array + items: + type: string + description: The list of teams the user is a member of + example: *a12 + required: + - id + - createdAt + - updatedAt + - lastLoginAt + - isActive + - name + - email + - role responseId: type: string description: The ID of the response @@ -4393,7 +4778,7 @@ components: required: - id - type - default: *a12 + default: *a14 description: The endings of the survey thankYouCard: type: @@ -4459,7 +4844,7 @@ components: description: Survey variables displayOption: type: string - enum: *a13 + enum: *a15 description: Display options for the survey recontactDays: type: @@ -4719,10 +5104,10 @@ components: properties: linkSurveys: type: string - enum: *a11 + enum: *a13 appSurveys: type: string - enum: *a11 + enum: *a13 required: - linkSurveys - appSurveys diff --git a/packages/database/zod/users.ts b/packages/database/zod/users.ts new file mode 100644 index 0000000000..35d1d32785 --- /dev/null +++ b/packages/database/zod/users.ts @@ -0,0 +1,88 @@ +import { OrganizationRole, User } from "@prisma/client"; +import { z } from "zod"; +import { extendZodWithOpenApi } from "zod-openapi"; +import { ZUserEmail, ZUserName } from "../../types/user"; + +extendZodWithOpenApi(z); + +const ZNoBillingOrganizationRoles = z.enum( + Object.values(OrganizationRole).filter((role) => role !== OrganizationRole.billing) as [ + OrganizationRole, + ...OrganizationRole[], + ] +); + +export type TNoBillingOrganizationRoles = z.infer; + +export const ZUser = z.object({ + id: z.string().cuid2().openapi({ + description: "The ID of the user", + }), + createdAt: z.coerce.date().openapi({ + description: "The date and time the user was created", + example: "2021-01-01T00:00:00.000Z", + }), + updatedAt: z.coerce.date().openapi({ + description: "The date and time the user was last updated", + example: "2021-01-01T00:00:00.000Z", + }), + lastLoginAt: z.coerce.date().openapi({ + description: "The date and time the user last logged in", + example: "2021-01-01T00:00:00.000Z", + }), + isActive: z.boolean().openapi({ + description: "Whether the user is active", + example: true, + }), + name: ZUserName.openapi({ + description: "The name of the user", + example: "John Doe", + }), + email: ZUserEmail.openapi({ + description: "The email of the user", + example: "example@example.com", + }), + role: ZNoBillingOrganizationRoles.openapi({ + description: "The role of the user in the organization", + example: OrganizationRole.member, + }), + teams: z + .array(z.string()) + .optional() + .openapi({ + description: "The list of teams the user is a member of", + example: ["team1", "team2"], + }), +}) satisfies z.ZodType< + Omit< + User, + | "emailVerified" + | "imageUrl" + | "twoFactorSecret" + | "twoFactorEnabled" + | "backupCodes" + | "password" + | "identityProvider" + | "identityProviderAccountId" + | "memberships" + | "accounts" + | "responseNotes" + | "groupId" + | "invitesCreated" + | "invitesAccepted" + | "objective" + | "notificationSettings" + | "locale" + | "surveys" + | "teamUsers" + | "role" //doesn't satisfy the type because we remove the billing role + | "deprecatedRole" + > +>; + +ZUser.openapi({ + ref: "user", + description: "A user", +}); + +export type TUser = z.infer; diff --git a/sonar-project.properties b/sonar-project.properties index bcce9834f1..ff19bd4f85 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,**/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,**/types/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts \ No newline at end of file