mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-29 18:00:26 -06:00
feat: user endpoints (#5232)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
|
||||
|
||||
export { GET, POST, PATCH };
|
||||
@@ -235,7 +235,7 @@ const internalServerErrorResponse = ({
|
||||
const successResponse = ({
|
||||
data,
|
||||
meta,
|
||||
cors = false,
|
||||
cors = true,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
data: Object;
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }] });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
131
apps/web/playwright/api/organization/user.spec.ts
Normal file
131
apps/web/playwright/api/organization/user.spec.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
88
packages/database/zod/users.ts
Normal file
88
packages/database/zod/users.ts
Normal 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>;
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user