feat: user endpoints (#5232)

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
victorvhs017
2025-04-06 03:06:18 -03:00
committed by GitHub
parent c653841037
commit 2c7f92a4d7
42 changed files with 1674 additions and 109 deletions

View File

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

View File

@@ -235,7 +235,7 @@ const internalServerErrorResponse = ({
const successResponse = ({
data,
meta,
cors = false,
cors = true,
cache = "private, no-store",
}: {
data: Object;

View File

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

View File

@@ -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: {

View File

@@ -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<Result<Respons
export const updateResponse = async (
responseId: string,
responseInput: z.infer<typeof responseUpdateSchema>
responseInput: z.infer<typeof ZResponseUpdateSchema>
): Promise<Result<Response, ApiErrorResponseV2>> => {
try {
const updatedResponse = await prisma.response.update({

View File

@@ -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;

View File

@@ -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({

View File

@@ -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: {

View File

@@ -134,12 +134,14 @@ export const getResponses = async (
params: TGetResponsesFilter
): Promise<Result<ApiResponseWithMeta<Response[]>, 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,
}),
]);

View File

@@ -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 });
},
});

View File

@@ -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: {

View File

@@ -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<typeof webhookUpdateSchema>;
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof ZWebhookUpdateSchema>;
test("returns ok on successful update", async () => {
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);

View File

@@ -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<typeof webhookUpdateSchema>
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
try {
const updatedWebhook = await prisma.webhook.update({

View File

@@ -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 }) => {

View File

@@ -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,

View File

@@ -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: {

View File

@@ -13,12 +13,14 @@ export const getWebhooks = async (
params: TGetWebhooksFilter
): Promise<Result<ApiResponseWithMeta<Webhook[]>, 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,
}),
]);

View File

@@ -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) => ({

View File

@@ -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: [

View File

@@ -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", () => {

View File

@@ -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");

View File

@@ -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,
},
},
},

View File

@@ -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<Result<ApiResponseWithMeta<ProjectTeam[]>, 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<typeof projectTeamUpdateSchema>
teamInput: z.infer<typeof ZProjectZTeamUpdateSchema>
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
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 }] });
}
};

View File

@@ -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) {

View File

@@ -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 });
},
});
}

View File

@@ -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,
});

View File

@@ -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: {

View File

@@ -48,12 +48,14 @@ export const getTeams = async (
params: TGetTeamsFilter
): Promise<Result<ApiResponseWithMeta<Team[]>, 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,
}),
]);

View File

@@ -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 });
},
});

View File

@@ -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.<br />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.<br />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.<br />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,
},
};

View File

@@ -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");
}
});
});
});

View File

@@ -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();
});
});

View File

@@ -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<Result<ApiResponseWithMeta<TUser[]>, 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<Result<TUser, ApiErrorResponseV2>> => {
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<Result<TUser, ApiErrorResponseV2>> => {
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<any>[];
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<any>[];
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;
};

View File

@@ -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<Prisma.UserFindManyArgs>(query, baseFilter);
}
return query;
};

View File

@@ -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 });
},
});

View File

@@ -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<typeof ZGetUsersFilter>;
export const ZUserInput = ZUser.omit({
id: true,
createdAt: true,
updatedAt: true,
lastLoginAt: true,
}).extend({
isActive: ZUser.shape.isActive.optional(),
});
export type TUserInput = z.infer<typeof ZUserInput>;
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<typeof ZUserInputPatch>;

View File

@@ -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`;

View File

@@ -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);

View File

@@ -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([]);
});
});
});

View File

@@ -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.<br />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.<br />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.<br />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

View File

@@ -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<typeof ZNoBillingOrganizationRoles>;
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<typeof ZUser>;

View File

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