mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-31 00:50:34 -06:00
feat: organization endpoints (#5076)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com> Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
This commit is contained in:
@@ -62,9 +62,27 @@ describe("getApiKeyWithPermissions", () => {
|
||||
|
||||
describe("hasPermission", () => {
|
||||
const permissions: TAPIKeyEnvironmentPermission[] = [
|
||||
{ environmentId: "env-1", permission: "manage" },
|
||||
{ environmentId: "env-2", permission: "write" },
|
||||
{ environmentId: "env-3", permission: "read" },
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
{
|
||||
environmentId: "env-2",
|
||||
permission: "write",
|
||||
environmentType: "production",
|
||||
projectId: "project-2",
|
||||
projectName: "Project 2",
|
||||
},
|
||||
{
|
||||
environmentId: "env-3",
|
||||
permission: "read",
|
||||
environmentType: "development",
|
||||
projectId: "project-3",
|
||||
projectName: "Project 3",
|
||||
},
|
||||
];
|
||||
|
||||
it("should return true for manage permission with any method", () => {
|
||||
@@ -108,7 +126,12 @@ describe("authenticateRequest", () => {
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage" as const,
|
||||
environment: { id: "env-1" },
|
||||
environment: {
|
||||
id: "env-1",
|
||||
projectId: "project-1",
|
||||
project: { name: "Project 1" },
|
||||
type: "development",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -121,7 +144,15 @@ describe("authenticateRequest", () => {
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [{ environmentId: "env-1", permission: "manage" }],
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
|
||||
@@ -21,11 +21,15 @@ export const authenticateRequest = async (request: Request): Promise<TAuthentica
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
environmentId: env.environmentId,
|
||||
environmentType: env.environment.type,
|
||||
permission: env.permission,
|
||||
projectId: env.environment.projectId,
|
||||
projectName: env.environment.project.name,
|
||||
})),
|
||||
hashedApiKey,
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
};
|
||||
|
||||
return authentication;
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { GET } from "@/modules/api/v2/management/roles/route";
|
||||
|
||||
export { GET };
|
||||
3
apps/web/app/api/v2/me/route.ts
Normal file
3
apps/web/app/api/v2/me/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/me/route";
|
||||
|
||||
export { GET };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, POST, PUT } from "@/modules/api/v2/organizations/[organizationId]/project-teams/route";
|
||||
|
||||
export { GET, POST, PUT, DELETE };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, PUT } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/organizations/[organizationId]/teams/route";
|
||||
|
||||
export { GET, POST };
|
||||
3
apps/web/app/api/v2/roles/route.ts
Normal file
3
apps/web/app/api/v2/roles/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/api/v2/roles/route";
|
||||
|
||||
export { GET };
|
||||
@@ -11,6 +11,7 @@ export const authenticateRequest = async (
|
||||
if (!apiKey) return err({ type: "unauthorized" });
|
||||
|
||||
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||
|
||||
if (!apiKeyData) return err({ type: "unauthorized" });
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
@@ -19,11 +20,15 @@ export const authenticateRequest = async (
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
environmentId: env.environmentId,
|
||||
environmentType: env.environment.type,
|
||||
permission: env.permission,
|
||||
projectId: env.environment.projectId,
|
||||
projectName: env.environment.project.name,
|
||||
})),
|
||||
hashedApiKey,
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
};
|
||||
return ok(authentication);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { apiWrapper } from "@/modules/api/v2/management/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/management/auth/authenticate-request";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
@@ -34,12 +34,22 @@ describe("authenticateRequest", () => {
|
||||
{
|
||||
environmentId: "env-id-1",
|
||||
permission: "manage",
|
||||
environment: { id: "env-id-1" },
|
||||
environment: {
|
||||
id: "env-id-1",
|
||||
projectId: "project-id-1",
|
||||
type: "development",
|
||||
project: { name: "Project 1" },
|
||||
},
|
||||
},
|
||||
{
|
||||
environmentId: "env-id-2",
|
||||
permission: "read",
|
||||
environment: { id: "env-id-2" },
|
||||
environment: {
|
||||
id: "env-id-2",
|
||||
projectId: "project-id-2",
|
||||
type: "production",
|
||||
project: { name: "Project 2" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -55,8 +65,20 @@ describe("authenticateRequest", () => {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{ environmentId: "env-id-1", permission: "manage" },
|
||||
{ environmentId: "env-id-2", permission: "read" },
|
||||
{
|
||||
environmentId: "env-id-1",
|
||||
permission: "manage",
|
||||
environmentType: "development",
|
||||
projectId: "project-id-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
{
|
||||
environmentId: "env-id-2",
|
||||
permission: "read",
|
||||
environmentType: "production",
|
||||
projectId: "project-id-2",
|
||||
projectName: "Project 2",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
@@ -122,9 +122,11 @@ const notFoundResponse = ({
|
||||
const conflictResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
details = [],
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
details?: ApiErrorDetails;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
@@ -136,6 +138,7 @@ const conflictResponse = ({
|
||||
error: {
|
||||
code: 409,
|
||||
message: "Conflict",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -85,13 +85,15 @@ describe("API Responses", () => {
|
||||
|
||||
describe("conflictResponse", () => {
|
||||
test("return a 409 response", async () => {
|
||||
const res = responses.conflictResponse();
|
||||
const details = [{ field: "resource", issue: "already exists" }];
|
||||
const res = responses.conflictResponse({ details });
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 409,
|
||||
message: "Conflict",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ export const handleApiError = (request: Request, err: ApiErrorResponseV2): Respo
|
||||
case "not_found":
|
||||
return responses.notFoundResponse({ details: err.details });
|
||||
case "conflict":
|
||||
return responses.conflictResponse();
|
||||
return responses.conflictResponse({ details: err.details });
|
||||
case "unprocessable_entity":
|
||||
return responses.unprocessableEntityResponse({ details: err.details });
|
||||
case "too_many_requests":
|
||||
|
||||
@@ -9,7 +9,7 @@ export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
||||
return { limit, skip, sortBy, order, startDate, endDate };
|
||||
}
|
||||
|
||||
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs;
|
||||
type HasFindMany = Prisma.WebhookFindManyArgs | Prisma.ResponseFindManyArgs | Prisma.TeamFindManyArgs | Prisma.ProjectTeamFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import {
|
||||
deleteResponse,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
@@ -22,7 +21,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Responses retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZResponse))),
|
||||
schema: responseWithMetaSchema(makePartialSchema(ZResponse)),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { ZGetResponsesFilter, ZResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponse } from "@/modules/api/v2/types/api-success";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getRoles = async (): Promise<Result<ApiResponse<string[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
// We use a raw query to get all the roles because we can't list enum options with prisma
|
||||
const results = await prisma.$queryRaw<{ unnest: string }[]>`
|
||||
SELECT unnest(enum_range(NULL::"OrganizationRole"));
|
||||
`;
|
||||
|
||||
if (!results) {
|
||||
// We set internal_server_error because it's an enum and we should always have the roles
|
||||
return err({ type: "internal_server_error", details: [{ field: "roles", issue: "not found" }] });
|
||||
}
|
||||
|
||||
const roles = results.map((row) => row.unnest);
|
||||
|
||||
return ok({
|
||||
data: roles,
|
||||
});
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "roles", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getRoles } from "../roles";
|
||||
|
||||
// Mock prisma with a $queryRaw function
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
$queryRaw: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getRoles", () => {
|
||||
it("returns roles on success", async () => {
|
||||
(prisma.$queryRaw as any).mockResolvedValueOnce([{ unnest: "ADMIN" }, { unnest: "MEMBER" }]);
|
||||
|
||||
const result = await getRoles();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual(["ADMIN", "MEMBER"]);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error if no results are found", async () => {
|
||||
(prisma.$queryRaw as any).mockResolvedValueOnce(null);
|
||||
|
||||
const result = await getRoles();
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error?.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns error on exception", async () => {
|
||||
vi.mocked(prisma.$queryRaw).mockRejectedValueOnce(new Error("Test DB error"));
|
||||
|
||||
const result = await getRoles();
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import { getContact } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/contacts";
|
||||
import { getResponse } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/response";
|
||||
|
||||
@@ -11,7 +11,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Gets a webhook from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Webhooks"],
|
||||
@@ -34,7 +34,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
@@ -56,7 +56,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: webhookIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
|
||||
import {
|
||||
deleteWebhook,
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/openapi";
|
||||
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
@@ -22,7 +21,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Webhooks retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(responseWithMetaSchema(makePartialSchema(ZWebhook))),
|
||||
schema: responseWithMetaSchema(makePartialSchema(ZWebhook)),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -60,7 +59,7 @@ export const webhookPaths: ZodOpenApiPathsObject = {
|
||||
get: getWebhooksEndpoint,
|
||||
post: createWebhookEndpoint,
|
||||
},
|
||||
"/webhooks/{webhookId}": {
|
||||
"/webhooks/{id}": {
|
||||
get: getWebhookEndpoint,
|
||||
put: updateWebhookEndpoint,
|
||||
delete: deleteWebhookEndpoint,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
|
||||
import { createWebhook, getWebhooks } from "@/modules/api/v2/management/webhooks/lib/webhook";
|
||||
import { ZGetWebhooksFilter, ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
|
||||
26
apps/web/modules/api/v2/me/lib/openapi.ts
Normal file
26
apps/web/modules/api/v2/me/lib/openapi.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZApiKeyData } from "@formbricks/database/zod/api-keys";
|
||||
|
||||
export const getMeEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "me",
|
||||
summary: "Me",
|
||||
description: "Fetches the projects and organizations associated with the API key.",
|
||||
tags: ["Me"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "API key information retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZApiKeyData),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mePaths: ZodOpenApiPathsObject = {
|
||||
"/me": {
|
||||
get: getMeEndpoint,
|
||||
},
|
||||
};
|
||||
23
apps/web/modules/api/v2/me/route.ts
Normal file
23
apps/web/modules/api/v2/me/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
handler: async ({ authentication }) => {
|
||||
return responses.successResponse({
|
||||
data: {
|
||||
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
|
||||
environmentId: permission.environmentId,
|
||||
environmentType: permission.environmentType,
|
||||
permissions: permission.permission,
|
||||
projectId: permission.projectId,
|
||||
projectName: permission.projectName,
|
||||
})),
|
||||
organizationId: authentication.organizationId,
|
||||
organizationAccess: authentication.organizationAccess,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
0
apps/web/modules/api/v2/me/types/me.ts
Normal file
0
apps/web/modules/api/v2/me/types/me.ts
Normal file
@@ -2,18 +2,25 @@ import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-at
|
||||
import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
|
||||
import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
|
||||
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { rolePaths } from "@/modules/api/v2/management/roles/lib/openapi";
|
||||
import { surveyPaths } from "@/modules/api/v2/management/surveys/lib/openapi";
|
||||
import { webhookPaths } from "@/modules/api/v2/management/webhooks/lib/openapi";
|
||||
import { mePaths } from "@/modules/api/v2/me/lib/openapi";
|
||||
import { projectTeamPaths } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/openapi";
|
||||
import { teamPaths } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/openapi";
|
||||
import { rolePaths } from "@/modules/api/v2/roles/lib/openapi";
|
||||
import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
|
||||
import * as yaml from "yaml";
|
||||
import { z } from "zod";
|
||||
import { createDocument, extendZodWithOpenApi } from "zod-openapi";
|
||||
import { ZApiKeyData } from "@formbricks/database/zod/api-keys";
|
||||
import { ZContact } from "@formbricks/database/zod/contact";
|
||||
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
|
||||
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
|
||||
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
import { ZRoles } from "@formbricks/database/zod/roles";
|
||||
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
|
||||
import { ZTeam } from "@formbricks/database/zod/teams";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
@@ -26,6 +33,8 @@ const document = createDocument({
|
||||
version: "2.0.0",
|
||||
},
|
||||
paths: {
|
||||
...rolePaths,
|
||||
...mePaths,
|
||||
...responsePaths,
|
||||
...bulkContactPaths,
|
||||
...contactPaths,
|
||||
@@ -33,7 +42,8 @@ const document = createDocument({
|
||||
...contactAttributeKeyPaths,
|
||||
...surveyPaths,
|
||||
...webhookPaths,
|
||||
...rolePaths,
|
||||
...teamPaths,
|
||||
...projectTeamPaths,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
@@ -42,6 +52,14 @@ const document = createDocument({
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: "Roles",
|
||||
description: "Operations for managing roles.",
|
||||
},
|
||||
{
|
||||
name: "Me",
|
||||
description: "Operations for managing your API key.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Responses",
|
||||
description: "Operations for managing responses.",
|
||||
@@ -67,8 +85,12 @@ const document = createDocument({
|
||||
description: "Operations for managing webhooks.",
|
||||
},
|
||||
{
|
||||
name: "Management API > Roles",
|
||||
description: "Operations for managing roles.",
|
||||
name: "Organizations API > Teams",
|
||||
description: "Operations for managing teams.",
|
||||
},
|
||||
{
|
||||
name: "Organizations API > Project Teams",
|
||||
description: "Operations for managing project teams.",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
@@ -81,13 +103,16 @@ const document = createDocument({
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
role: ZRoles,
|
||||
me: ZApiKeyData,
|
||||
response: ZResponse,
|
||||
contact: ZContact,
|
||||
contactAttribute: ZContactAttribute,
|
||||
contactAttributeKey: ZContactAttributeKey,
|
||||
survey: ZSurveyWithoutQuestionType,
|
||||
webhook: ZWebhook,
|
||||
role: z.array(z.string()),
|
||||
team: ZTeam,
|
||||
projectTeam: ZProjectTeam,
|
||||
},
|
||||
},
|
||||
security: [
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import { hasOrganizationIdAndAccess } from "./utils";
|
||||
|
||||
describe("hasOrganizationIdAndAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return false and log error if authentication has no organizationId", () => {
|
||||
const spyError = vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
const authentication = {
|
||||
organizationAccess: { accessControl: { read: true } },
|
||||
} as any;
|
||||
|
||||
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
|
||||
expect(result).toBe(false);
|
||||
expect(spyError).toHaveBeenCalledWith("Organization ID is missing from the authentication object");
|
||||
});
|
||||
|
||||
it("should return false and log error if param organizationId does not match authentication organizationId", () => {
|
||||
const spyError = vi.spyOn(logger, "error").mockImplementation(() => {});
|
||||
const authentication = {
|
||||
organizationId: "org2",
|
||||
organizationAccess: { accessControl: { read: true } },
|
||||
} as any;
|
||||
|
||||
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
|
||||
expect(result).toBe(false);
|
||||
expect(spyError).toHaveBeenCalledWith(
|
||||
"Organization ID from params does not match the authenticated organization ID"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false if access type is missing in organizationAccess", () => {
|
||||
const authentication = {
|
||||
organizationId: "org1",
|
||||
organizationAccess: { accessControl: {} },
|
||||
} as any;
|
||||
|
||||
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if organizationId and access type are valid", () => {
|
||||
const authentication = {
|
||||
organizationId: "org1",
|
||||
organizationAccess: { accessControl: { read: true } },
|
||||
} as any;
|
||||
|
||||
const result = hasOrganizationIdAndAccess("org1", authentication, "read" as OrganizationAccessType);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
export const hasOrganizationIdAndAccess = (
|
||||
paramOrganizationId: string,
|
||||
authentication: TAuthenticationApiKey,
|
||||
accessType: OrganizationAccessType
|
||||
): boolean => {
|
||||
if (!authentication.organizationId) {
|
||||
logger.error("Organization ID is missing from the authentication object");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (paramOrganizationId !== authentication.organizationId) {
|
||||
logger.error("Organization ID from params does not match the authenticated organization ID");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!authentication.organizationAccess?.accessControl?.[accessType]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
ZGetProjectTeamUpdateFilter,
|
||||
ZGetProjectTeamsFilter,
|
||||
ZProjectTeamInput,
|
||||
projectTeamUpdateSchema,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
|
||||
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
|
||||
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
|
||||
|
||||
export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getProjectTeams",
|
||||
summary: "Get project teams",
|
||||
description: "Gets projectTeams from the database.",
|
||||
requestParams: {
|
||||
query: ZGetProjectTeamsFilter.sourceType().required(),
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API > Project Teams"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Project teams retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: responseWithMetaSchema(makePartialSchema(ZProjectTeam)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createProjectTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createProjectTeam",
|
||||
summary: "Create a projectTeam",
|
||||
description: "Creates a project team in the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API > Project Teams"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The project team to create",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZProjectTeamInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Project team created successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZProjectTeam),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteProjectTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteProjectTeam",
|
||||
summary: "Delete a project team",
|
||||
description: "Deletes a project team from the database.",
|
||||
tags: ["Organizations API > Project Teams"],
|
||||
requestParams: {
|
||||
query: ZGetProjectTeamUpdateFilter.required(),
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Project team deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZProjectTeam),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateProjectTeam",
|
||||
summary: "Update a project team",
|
||||
description: "Updates a project team in the database.",
|
||||
tags: ["Organizations API > Project Teams"],
|
||||
requestParams: {
|
||||
query: ZGetProjectTeamUpdateFilter.required(),
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The project team to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: projectTeamUpdateSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Project team updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZProjectTeam),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const projectTeamPaths: ZodOpenApiPathsObject = {
|
||||
"/{organizationId}/project-teams": {
|
||||
servers: organizationServer,
|
||||
get: getProjectTeamsEndpoint,
|
||||
post: createProjectTeamEndpoint,
|
||||
put: updateProjectTeamEndpoint,
|
||||
delete: deleteProjectTeamEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
|
||||
import {
|
||||
TGetProjectTeamsFilter,
|
||||
TProjectTeamInput,
|
||||
projectTeamUpdateSchema,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
|
||||
import { ProjectTeam } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { projectCache } from "@formbricks/lib/project/cache";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getProjectTeams = async (
|
||||
organizationId: string,
|
||||
params: TGetProjectTeamsFilter
|
||||
): Promise<Result<ApiResponseWithMeta<ProjectTeam[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const [projectTeams, count] = await prisma.$transaction([
|
||||
prisma.projectTeam.findMany({
|
||||
...getProjectTeamsQuery(organizationId, params),
|
||||
}),
|
||||
prisma.projectTeam.count({
|
||||
where: getProjectTeamsQuery(organizationId, params).where,
|
||||
}),
|
||||
]);
|
||||
|
||||
return ok({
|
||||
data: projectTeams,
|
||||
meta: {
|
||||
total: count,
|
||||
limit: params.limit,
|
||||
offset: params.skip,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "projectTeam", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
|
||||
export const createProjectTeam = async (
|
||||
teamInput: TProjectTeamInput
|
||||
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("project team created");
|
||||
|
||||
const { teamId, projectId, permission } = teamInput;
|
||||
|
||||
try {
|
||||
const projectTeam = await prisma.projectTeam.create({
|
||||
data: {
|
||||
teamId,
|
||||
projectId,
|
||||
permission,
|
||||
},
|
||||
});
|
||||
|
||||
projectCache.revalidate({
|
||||
id: projectId,
|
||||
});
|
||||
|
||||
teamCache.revalidate({
|
||||
id: teamId,
|
||||
});
|
||||
|
||||
return ok(projectTeam);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateProjectTeam = async (
|
||||
teamId: string,
|
||||
projectId: string,
|
||||
teamInput: z.infer<typeof projectTeamUpdateSchema>
|
||||
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedProjectTeam = await prisma.projectTeam.update({
|
||||
where: {
|
||||
projectId_teamId: {
|
||||
projectId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
data: teamInput,
|
||||
});
|
||||
|
||||
projectCache.revalidate({
|
||||
id: projectId,
|
||||
});
|
||||
|
||||
teamCache.revalidate({
|
||||
id: teamId,
|
||||
});
|
||||
|
||||
return ok(updatedProjectTeam);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteProjectTeam = async (
|
||||
teamId: string,
|
||||
projectId: string
|
||||
): Promise<Result<ProjectTeam, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const deletedProjectTeam = await prisma.projectTeam.delete({
|
||||
where: {
|
||||
projectId_teamId: {
|
||||
projectId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
projectCache.revalidate({
|
||||
id: projectId,
|
||||
});
|
||||
|
||||
teamCache.revalidate({
|
||||
id: teamId,
|
||||
});
|
||||
|
||||
return ok(deletedProjectTeam);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "projecteam", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
TGetProjectTeamsFilter,
|
||||
TProjectTeamInput,
|
||||
projectTeamUpdateSchema,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TypeOf } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { createProjectTeam, deleteProjectTeam, getProjectTeams, updateProjectTeam } from "../project-teams";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
projectTeam: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("ProjectTeams Lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getProjectTeams", () => {
|
||||
it("returns projectTeams with meta on success", async () => {
|
||||
const mockTeams = [{ id: "projTeam1", organizationId: "orgx", projectId: "p1", teamId: "t1" }];
|
||||
(prisma.$transaction as any).mockResolvedValueOnce([mockTeams, mockTeams.length]);
|
||||
const result = await getProjectTeams("orgx", { skip: 0, limit: 10 } as TGetProjectTeamsFilter);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.data).toEqual(mockTeams);
|
||||
expect(result.data.meta).not.toBeNull();
|
||||
if (result.data.meta) {
|
||||
expect(result.data.meta.total).toBe(mockTeams.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("returns internal_server_error on exception", async () => {
|
||||
(prisma.$transaction as any).mockRejectedValueOnce(new Error("DB error"));
|
||||
const result = await getProjectTeams("orgx", { skip: 0, limit: 10 } as TGetProjectTeamsFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("createProjectTeam", () => {
|
||||
it("creates a projectTeam successfully", async () => {
|
||||
const mockCreated = { id: "ptx", projectId: "p1", teamId: "t1", organizationId: "orgx" };
|
||||
(prisma.projectTeam.create as any).mockResolvedValueOnce(mockCreated);
|
||||
const result = await createProjectTeam({
|
||||
projectId: "p1",
|
||||
teamId: "t1",
|
||||
} as TProjectTeamInput);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect((result.data as any).id).toBe("ptx");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns internal_server_error on error", async () => {
|
||||
(prisma.projectTeam.create as any).mockRejectedValueOnce(new Error("Create error"));
|
||||
const result = await createProjectTeam({
|
||||
projectId: "p1",
|
||||
teamId: "t1",
|
||||
} as TProjectTeamInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateProjectTeam", () => {
|
||||
it("updates a projectTeam successfully", async () => {
|
||||
(prisma.projectTeam.update as any).mockResolvedValueOnce({
|
||||
id: "pt01",
|
||||
projectId: "p1",
|
||||
teamId: "t1",
|
||||
permission: "READ",
|
||||
});
|
||||
const result = await updateProjectTeam("t1", "p1", { permission: "READ" } as unknown as TypeOf<
|
||||
typeof projectTeamUpdateSchema
|
||||
>);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.permission).toBe("READ");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns internal_server_error on error", async () => {
|
||||
(prisma.projectTeam.update as any).mockRejectedValueOnce(new Error("Update error"));
|
||||
const result = await updateProjectTeam("t1", "p1", { permission: "READ" } as unknown as TypeOf<
|
||||
typeof projectTeamUpdateSchema
|
||||
>);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteProjectTeam", () => {
|
||||
it("deletes a projectTeam successfully", async () => {
|
||||
(prisma.projectTeam.delete as any).mockResolvedValueOnce({
|
||||
projectId: "p1",
|
||||
teamId: "t1",
|
||||
permission: "READ",
|
||||
});
|
||||
const result = await deleteProjectTeam("t1", "p1");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.projectId).toBe("p1");
|
||||
expect(result.data.teamId).toBe("t1");
|
||||
}
|
||||
});
|
||||
|
||||
it("returns internal_server_error on error", async () => {
|
||||
(prisma.projectTeam.delete as any).mockRejectedValueOnce(new Error("Delete error"));
|
||||
const result = await deleteProjectTeam("t1", "p1");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TGetProjectTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import { projectCache } from "@formbricks/lib/project/cache";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getProjectTeamsQuery = (organizationId: string, params: TGetProjectTeamsFilter) => {
|
||||
const { teamId, projectId } = params || {};
|
||||
|
||||
let query: Prisma.ProjectTeamFindManyArgs = {
|
||||
where: {
|
||||
team: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (teamId) {
|
||||
query = {
|
||||
...query,
|
||||
where: {
|
||||
...query.where,
|
||||
teamId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
query = {
|
||||
...query,
|
||||
where: {
|
||||
...query.where,
|
||||
projectId,
|
||||
project: {
|
||||
organizationId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseFilter = pickCommonFilter(params);
|
||||
|
||||
if (baseFilter) {
|
||||
query = buildCommonFilterQuery<Prisma.ProjectTeamFindManyArgs>(query, baseFilter);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
export const validateTeamIdAndProjectId = reactCache(
|
||||
async (organizationId: string, teamId: string, projectId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<boolean, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const hasAccess = await prisma.organization.findFirst({
|
||||
where: {
|
||||
id: organizationId,
|
||||
teams: {
|
||||
some: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
some: {
|
||||
id: projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasAccess) {
|
||||
return err({ type: "not_found", details: [{ field: "teamId/projectId", issue: "not_found" }] });
|
||||
}
|
||||
|
||||
return ok(true);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "teamId/projectId", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`validateTeamIdAndProjectId-${organizationId}-${teamId}-${projectId}`],
|
||||
{
|
||||
tags: [
|
||||
teamCache.tag.byId(teamId),
|
||||
projectCache.tag.byId(projectId),
|
||||
organizationCache.tag.byId(organizationId),
|
||||
],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const checkAuthenticationAndAccess = async (
|
||||
teamId: string,
|
||||
projectId: string,
|
||||
authentication: TAuthenticationApiKey
|
||||
): Promise<Result<boolean, ApiErrorResponseV2>> => {
|
||||
const hasAccess = await validateTeamIdAndProjectId(authentication.organizationId, teamId, projectId);
|
||||
|
||||
if (!hasAccess.ok) {
|
||||
return err(hasAccess.error);
|
||||
}
|
||||
|
||||
return ok(true);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
|
||||
import { checkAuthenticationAndAccess } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
|
||||
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
|
||||
import { z } from "zod";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
import {
|
||||
createProjectTeam,
|
||||
deleteProjectTeam,
|
||||
getProjectTeams,
|
||||
updateProjectTeam,
|
||||
} from "./lib/project-teams";
|
||||
import {
|
||||
ZGetProjectTeamUpdateFilter,
|
||||
ZGetProjectTeamsFilter,
|
||||
ZProjectTeamInput,
|
||||
projectTeamUpdateSchema,
|
||||
} from "./types/project-teams";
|
||||
|
||||
export async function GET(request: Request, props: { params: Promise<{ organizationId: string }> }) {
|
||||
return authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
query: ZGetProjectTeamsFilter.sourceType(),
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ parsedInput: { query, params }, authentication }) => {
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const result = await getProjectTeams(authentication.organizationId, query!);
|
||||
|
||||
if (!result.ok) {
|
||||
return handleApiError(request, result.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(result.data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(request: Request, props: { params: Promise<{ organizationId: string }> }) {
|
||||
return authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
body: ZProjectTeamInput,
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ parsedInput: { body, params }, authentication }) => {
|
||||
const { teamId, projectId } = body!;
|
||||
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
|
||||
|
||||
if (!hasAccess.ok) {
|
||||
return handleApiError(request, hasAccess.error);
|
||||
}
|
||||
|
||||
// check if project team already exists
|
||||
const existingProjectTeam = await getProjectTeams(authentication.organizationId, {
|
||||
teamId,
|
||||
projectId,
|
||||
limit: 10,
|
||||
skip: 0,
|
||||
sortBy: "createdAt",
|
||||
order: "desc",
|
||||
});
|
||||
|
||||
if (!existingProjectTeam.ok) {
|
||||
return handleApiError(request, existingProjectTeam.error);
|
||||
}
|
||||
|
||||
if (existingProjectTeam.data.data.length > 0) {
|
||||
return handleApiError(request, {
|
||||
type: "conflict",
|
||||
details: [{ field: "projectTeam", issue: "Project team already exists" }],
|
||||
});
|
||||
}
|
||||
const result = await createProjectTeam(body!);
|
||||
if (!result.ok) {
|
||||
return handleApiError(request, result.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: result.data, cors: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, props: { params: Promise<{ organizationId: string }> }) {
|
||||
return authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
body: projectTeamUpdateSchema,
|
||||
query: ZGetProjectTeamUpdateFilter,
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ parsedInput: { query, body, params }, authentication }) => {
|
||||
const { teamId, projectId } = query!;
|
||||
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
|
||||
|
||||
if (!hasAccess.ok) {
|
||||
return handleApiError(request, hasAccess.error);
|
||||
}
|
||||
|
||||
const result = await updateProjectTeam(teamId, projectId, body!);
|
||||
if (!result.ok) {
|
||||
return handleApiError(request, result.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: result.data, cors: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, props: { params: Promise<{ organizationId: string }> }) {
|
||||
return authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
query: ZGetProjectTeamUpdateFilter,
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ parsedInput: { query, params }, authentication }) => {
|
||||
const { teamId, projectId } = query!;
|
||||
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const hasAccess = await checkAuthenticationAndAccess(teamId, projectId, authentication);
|
||||
|
||||
if (!hasAccess.ok) {
|
||||
return handleApiError(request, hasAccess.error);
|
||||
}
|
||||
|
||||
const result = await deleteProjectTeam(teamId, projectId);
|
||||
if (!result.ok) {
|
||||
return handleApiError(request, result.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: result.data, cors: true });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
import { ZProjectTeam } from "@formbricks/database/zod/project-teams";
|
||||
|
||||
export const ZGetProjectTeamsFilter = ZGetFilter.extend({
|
||||
teamId: z.string().cuid2().optional(),
|
||||
projectId: z.string().cuid2().optional(),
|
||||
}).refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
}
|
||||
);
|
||||
|
||||
export type TGetProjectTeamsFilter = z.infer<typeof ZGetProjectTeamsFilter>;
|
||||
|
||||
export const ZProjectTeamInput = ZProjectTeam.pick({
|
||||
teamId: true,
|
||||
projectId: true,
|
||||
permission: true,
|
||||
});
|
||||
|
||||
export type TProjectTeamInput = z.infer<typeof ZProjectTeamInput>;
|
||||
|
||||
export const ZGetProjectTeamUpdateFilter = z.object({
|
||||
teamId: z.string().cuid2(),
|
||||
projectId: z.string().cuid2(),
|
||||
});
|
||||
|
||||
export const projectTeamUpdateSchema = ZProjectTeam.pick({
|
||||
permission: true,
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ZTeamIdSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
|
||||
import { ZTeamInput } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
|
||||
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZTeam } from "@formbricks/database/zod/teams";
|
||||
|
||||
export const getTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getTeam",
|
||||
summary: "Get a team",
|
||||
description: "Gets a team from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZTeamIdSchema,
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API > Teams"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Team retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZTeam),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteTeam",
|
||||
summary: "Delete a team",
|
||||
description: "Deletes a team from the database.",
|
||||
tags: ["Organizations API > Teams"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZTeamIdSchema,
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Team deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZTeam),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateTeam",
|
||||
summary: "Update a team",
|
||||
description: "Updates a team in the database.",
|
||||
tags: ["Organizations API > Teams"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: ZTeamIdSchema,
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The team to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZTeamInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Team updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZTeam),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import { organizationCache } from "@/lib/cache/organization";
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { ZTeamUpdateSchema } from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Team } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getTeam = reactCache(async (organizationId: string, teamId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Team, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const responsePrisma = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!responsePrisma) {
|
||||
return err({ type: "not_found", details: [{ field: "team", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(responsePrisma);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "team", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`organizationId-${organizationId}-getTeam-${teamId}`],
|
||||
{
|
||||
tags: [teamCache.tag.byId(teamId), organizationCache.tag.byId(organizationId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const deleteTeam = async (
|
||||
organizationId: string,
|
||||
teamId: string
|
||||
): Promise<Result<Team, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const deletedTeam = await prisma.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
organizationId,
|
||||
},
|
||||
include: {
|
||||
projectTeams: {
|
||||
select: {
|
||||
projectId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
teamCache.revalidate({
|
||||
id: deletedTeam.id,
|
||||
organizationId: deletedTeam.organizationId,
|
||||
});
|
||||
|
||||
for (const projectTeam of deletedTeam.projectTeams) {
|
||||
teamCache.revalidate({
|
||||
projectId: projectTeam.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
return ok(deletedTeam);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "team", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "team", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateTeam = async (
|
||||
organizationId: string,
|
||||
teamId: string,
|
||||
teamInput: z.infer<typeof ZTeamUpdateSchema>
|
||||
): Promise<Result<Team, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedTeam = await prisma.team.update({
|
||||
where: {
|
||||
id: teamId,
|
||||
organizationId,
|
||||
},
|
||||
data: teamInput,
|
||||
include: {
|
||||
projectTeams: { select: { projectId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
teamCache.revalidate({
|
||||
id: updatedTeam.id,
|
||||
organizationId: updatedTeam.organizationId,
|
||||
});
|
||||
|
||||
for (const projectTeam of updatedTeam.projectTeams) {
|
||||
teamCache.revalidate({
|
||||
projectId: projectTeam.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
return ok(updatedTeam);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (
|
||||
error.code === PrismaErrorType.RecordDoesNotExist ||
|
||||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
|
||||
) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "team", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "team", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { deleteTeam, getTeam, updateTeam } from "../teams";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
team: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Define a mock team
|
||||
const mockTeam = {
|
||||
id: "team123",
|
||||
organizationId: "org456",
|
||||
name: "Test Team",
|
||||
projectTeams: [{ projectId: "proj1" }, { projectId: "proj2" }],
|
||||
};
|
||||
|
||||
describe("Teams Lib", () => {
|
||||
describe("getTeam", () => {
|
||||
it("returns the team when found", async () => {
|
||||
(prisma.team.findUnique as any).mockResolvedValueOnce(mockTeam);
|
||||
const result = await getTeam("org456", "team123");
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockTeam);
|
||||
}
|
||||
expect(prisma.team.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "team123", organizationId: "org456" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a not_found error when team is missing", async () => {
|
||||
(prisma.team.findUnique as any).mockResolvedValueOnce(null);
|
||||
const result = await getTeam("org456", "team123");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "team", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns an internal_server_error when prisma throws", async () => {
|
||||
(prisma.team.findUnique as any).mockRejectedValueOnce(new Error("DB error"));
|
||||
const result = await getTeam("org456", "team123");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteTeam", () => {
|
||||
it("deletes the team and revalidates cache", async () => {
|
||||
(prisma.team.delete as any).mockResolvedValueOnce(mockTeam);
|
||||
// Mock teamCache.revalidate
|
||||
const revalidateMock = vi.spyOn(teamCache, "revalidate").mockImplementation(() => {});
|
||||
const result = await deleteTeam("org456", "team123");
|
||||
expect(prisma.team.delete).toHaveBeenCalledWith({
|
||||
where: { id: "team123", organizationId: "org456" },
|
||||
include: { projectTeams: { select: { projectId: true } } },
|
||||
});
|
||||
expect(revalidateMock).toHaveBeenCalledWith({
|
||||
id: mockTeam.id,
|
||||
organizationId: mockTeam.organizationId,
|
||||
});
|
||||
for (const pt of mockTeam.projectTeams) {
|
||||
expect(revalidateMock).toHaveBeenCalledWith({ projectId: pt.projectId });
|
||||
}
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(mockTeam);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns not_found error on known prisma error", async () => {
|
||||
(prisma.team.delete as any).mockRejectedValueOnce(
|
||||
new PrismaClientKnownRequestError("Not found", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {},
|
||||
})
|
||||
);
|
||||
const result = await deleteTeam("org456", "team123");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "team", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns internal_server_error on exception", async () => {
|
||||
(prisma.team.delete as any).mockRejectedValueOnce(new Error("Delete failed"));
|
||||
const result = await deleteTeam("org456", "team123");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateTeam", () => {
|
||||
const updateInput = { name: "Updated Team" };
|
||||
const updatedTeam = { ...mockTeam, ...updateInput };
|
||||
|
||||
it("updates the team successfully and revalidates cache", async () => {
|
||||
(prisma.team.update as any).mockResolvedValueOnce(updatedTeam);
|
||||
const revalidateMock = vi.spyOn(teamCache, "revalidate").mockImplementation(() => {});
|
||||
const result = await updateTeam("org456", "team123", updateInput);
|
||||
expect(prisma.team.update).toHaveBeenCalledWith({
|
||||
where: { id: "team123", organizationId: "org456" },
|
||||
data: updateInput,
|
||||
include: { projectTeams: { select: { projectId: true } } },
|
||||
});
|
||||
expect(revalidateMock).toHaveBeenCalledWith({
|
||||
id: updatedTeam.id,
|
||||
organizationId: updatedTeam.organizationId,
|
||||
});
|
||||
for (const pt of updatedTeam.projectTeams) {
|
||||
expect(revalidateMock).toHaveBeenCalledWith({ projectId: pt.projectId });
|
||||
}
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(updatedTeam);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns not_found error when update fails due to missing team", async () => {
|
||||
(prisma.team.update as any).mockRejectedValueOnce(
|
||||
new PrismaClientKnownRequestError("Not found", {
|
||||
code: PrismaErrorType.RecordDoesNotExist,
|
||||
clientVersion: "1.0.0",
|
||||
meta: {},
|
||||
})
|
||||
);
|
||||
const result = await updateTeam("org456", "team123", updateInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "team", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns internal_server_error on generic exception", async () => {
|
||||
(prisma.team.update as any).mockRejectedValueOnce(new Error("Update failed"));
|
||||
const result = await updateTeam("org456", "team123", updateInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
|
||||
import {
|
||||
deleteTeam,
|
||||
getTeam,
|
||||
updateTeam,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/teams";
|
||||
import {
|
||||
ZTeamIdSchema,
|
||||
ZTeamUpdateSchema,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/types/teams";
|
||||
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
|
||||
import { z } from "zod";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ teamId: string; organizationId: string }> }
|
||||
) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput: { params } }) => {
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const team = await getTeam(params!.organizationId, params!.teamId);
|
||||
if (!team.ok) {
|
||||
return handleApiError(request, team.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(team);
|
||||
},
|
||||
});
|
||||
|
||||
export const DELETE = async (
|
||||
request: Request,
|
||||
props: { params: Promise<{ teamId: string; organizationId: string }> }
|
||||
) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput: { params } }) => {
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const team = await deleteTeam(params!.organizationId, params!.teamId);
|
||||
|
||||
if (!team.ok) {
|
||||
return handleApiError(request, team.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(team);
|
||||
},
|
||||
});
|
||||
|
||||
export const PUT = (
|
||||
request: Request,
|
||||
props: { params: Promise<{ teamId: string; organizationId: string }> }
|
||||
) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
externalParams: props.params,
|
||||
schemas: {
|
||||
params: z.object({ teamId: ZTeamIdSchema, organizationId: ZOrganizationIdSchema }),
|
||||
body: ZTeamUpdateSchema,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput: { body, params } }) => {
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const team = await updateTeam(params!.organizationId, params!.teamId, body!);
|
||||
|
||||
if (!team.ok) {
|
||||
return handleApiError(request, team.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(team);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
import { ZTeam } from "@formbricks/database/zod/teams";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZTeamIdSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
ref: "teamId",
|
||||
description: "The ID of the team",
|
||||
param: {
|
||||
name: "id",
|
||||
in: "path",
|
||||
},
|
||||
});
|
||||
|
||||
export const ZTeamUpdateSchema = ZTeam.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
organizationId: true,
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
deleteTeamEndpoint,
|
||||
getTeamEndpoint,
|
||||
updateTeamEndpoint,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/teams/[teamId]/lib/openapi";
|
||||
import {
|
||||
ZGetTeamsFilter,
|
||||
ZTeamInput,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
|
||||
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
|
||||
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
|
||||
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZTeam } from "@formbricks/database/zod/teams";
|
||||
|
||||
export const getTeamsEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getTeams",
|
||||
summary: "Get teams",
|
||||
description: "Gets teams from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
query: ZGetTeamsFilter.sourceType().required(),
|
||||
},
|
||||
tags: ["Organizations API > Teams"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Teams retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: responseWithMetaSchema(makePartialSchema(ZTeam)),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createTeamEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createTeam",
|
||||
summary: "Create a team",
|
||||
description: "Creates a team in the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Organizations API > Teams"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The team to create",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZTeamInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Team created successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: makePartialSchema(ZTeam),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const teamPaths: ZodOpenApiPathsObject = {
|
||||
"/{organizationId}/teams": {
|
||||
servers: organizationServer,
|
||||
get: getTeamsEndpoint,
|
||||
post: createTeamEndpoint,
|
||||
},
|
||||
"/{organizationId}/teams/{id}": {
|
||||
servers: organizationServer,
|
||||
get: getTeamEndpoint,
|
||||
put: updateTeamEndpoint,
|
||||
delete: deleteTeamEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import "server-only";
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { getTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/utils";
|
||||
import {
|
||||
TGetTeamsFilter,
|
||||
TTeamInput,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
|
||||
import { Team } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const createTeam = async (
|
||||
teamInput: TTeamInput,
|
||||
organizationId: string
|
||||
): Promise<Result<Team, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("team created");
|
||||
|
||||
const { name } = teamInput;
|
||||
|
||||
try {
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name,
|
||||
organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
organizationCache.revalidate({
|
||||
id: organizationId,
|
||||
});
|
||||
|
||||
teamCache.revalidate({
|
||||
organizationId: organizationId,
|
||||
});
|
||||
|
||||
return ok(team);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "team", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
|
||||
export const getTeams = async (
|
||||
organizationId: string,
|
||||
params: TGetTeamsFilter
|
||||
): Promise<Result<ApiResponseWithMeta<Team[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const [teams, count] = await prisma.$transaction([
|
||||
prisma.team.findMany({
|
||||
...getTeamsQuery(organizationId, params),
|
||||
}),
|
||||
prisma.team.count({
|
||||
where: getTeamsQuery(organizationId, params).where,
|
||||
}),
|
||||
]);
|
||||
|
||||
return ok({
|
||||
data: teams,
|
||||
meta: {
|
||||
total: count,
|
||||
limit: params.limit,
|
||||
offset: params.skip,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "teams", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import { TGetTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { organizationCache } from "@formbricks/lib/organization/cache";
|
||||
import { createTeam, getTeams } from "../teams";
|
||||
|
||||
// Define a mock team object
|
||||
const mockTeam = {
|
||||
id: "team123",
|
||||
organizationId: "org456",
|
||||
name: "Test Team",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Mock prisma methods
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
team: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock organizationCache.revalidate
|
||||
vi.spyOn(organizationCache, "revalidate").mockImplementation(() => {});
|
||||
|
||||
describe("Teams Lib", () => {
|
||||
describe("createTeam", () => {
|
||||
it("creates a team successfully and revalidates cache", async () => {
|
||||
(prisma.team.create as any).mockResolvedValueOnce(mockTeam);
|
||||
|
||||
const teamInput = { name: "Test Team" };
|
||||
const organizationId = "org456";
|
||||
const result = await createTeam(teamInput, organizationId);
|
||||
expect(prisma.team.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
organizationId: organizationId,
|
||||
},
|
||||
});
|
||||
expect(organizationCache.revalidate).toHaveBeenCalledWith({ id: organizationId });
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.data).toEqual(mockTeam);
|
||||
});
|
||||
|
||||
it("returns internal error when prisma.team.create fails", async () => {
|
||||
(prisma.team.create as any).mockRejectedValueOnce(new Error("Create error"));
|
||||
const teamInput = { name: "Test Team" };
|
||||
const organizationId = "org456";
|
||||
const result = await createTeam(teamInput, organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toEqual("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTeams", () => {
|
||||
const filter = { limit: 10, skip: 0 };
|
||||
it("returns teams with meta on success", async () => {
|
||||
const teamsArray = [mockTeam];
|
||||
// Simulate prisma transaction return [teams, count]
|
||||
(prisma.$transaction as any).mockResolvedValueOnce([teamsArray, teamsArray.length]);
|
||||
|
||||
const organizationId = "org456";
|
||||
const result = await getTeams(organizationId, filter as TGetTeamsFilter);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: teamsArray,
|
||||
meta: { total: teamsArray.length, limit: filter.limit, offset: filter.skip },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("returns internal_server_error when prisma transaction fails", async () => {
|
||||
(prisma.$transaction as any).mockRejectedValueOnce(new Error("Transaction error"));
|
||||
const organizationId = "org456";
|
||||
const result = await getTeams(organizationId, filter as TGetTeamsFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toEqual("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getTeamsQuery } from "../utils";
|
||||
|
||||
// Mock the common utils functions
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
pickCommonFilter: vi.fn(),
|
||||
buildCommonFilterQuery: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getTeamsQuery", () => {
|
||||
const organizationId = "org123";
|
||||
|
||||
it("returns base query when no params provided", () => {
|
||||
const result = getTeamsQuery(organizationId);
|
||||
expect(result.where).toEqual({ organizationId });
|
||||
});
|
||||
|
||||
it("returns unchanged query if pickCommonFilter returns null/undefined", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValueOnce(null as any);
|
||||
const params: any = { someParam: "test" };
|
||||
const result = getTeamsQuery(organizationId, params);
|
||||
expect(pickCommonFilter).toHaveBeenCalledWith(params);
|
||||
// Since pickCommonFilter returns undefined, query remains as base query.
|
||||
expect(result.where).toEqual({ organizationId });
|
||||
});
|
||||
|
||||
it("calls buildCommonFilterQuery and returns updated query when base filter exists", () => {
|
||||
const baseFilter = { key: "value" };
|
||||
vi.mocked(pickCommonFilter).mockReturnValueOnce(baseFilter as any);
|
||||
// Simulate buildCommonFilterQuery to merge base query with baseFilter
|
||||
const updatedQuery = { where: { organizationId, combined: true } } as Prisma.TeamFindManyArgs;
|
||||
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce(updatedQuery);
|
||||
|
||||
const params: any = { someParam: "test" };
|
||||
const result = getTeamsQuery(organizationId, params);
|
||||
|
||||
expect(pickCommonFilter).toHaveBeenCalledWith(params);
|
||||
expect(buildCommonFilterQuery).toHaveBeenCalledWith({ where: { organizationId } }, baseFilter);
|
||||
expect(result).toEqual(updatedQuery);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
|
||||
import { TGetTeamsFilter } from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const getTeamsQuery = (organizationId: string, params?: TGetTeamsFilter) => {
|
||||
let query: Prisma.TeamFindManyArgs = {
|
||||
where: {
|
||||
organizationId,
|
||||
},
|
||||
};
|
||||
|
||||
if (!params) return query;
|
||||
|
||||
const baseFilter = pickCommonFilter(params);
|
||||
|
||||
if (baseFilter) {
|
||||
query = buildCommonFilterQuery<Prisma.TeamFindManyArgs>(query, baseFilter);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { hasOrganizationIdAndAccess } from "@/modules/api/v2/organizations/[organizationId]/lib/utils";
|
||||
import { createTeam, getTeams } from "@/modules/api/v2/organizations/[organizationId]/teams/lib/teams";
|
||||
import {
|
||||
ZGetTeamsFilter,
|
||||
ZTeamInput,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
|
||||
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
|
||||
export const GET = async (request: NextRequest, props: { params: Promise<{ organizationId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
query: ZGetTeamsFilter.sourceType(),
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput: { query, params } }) => {
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Read)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const res = await getTeams(authentication.organizationId, query!);
|
||||
|
||||
if (!res.ok) {
|
||||
return handleApiError(request, res.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(res.data);
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = async (request: Request, props: { params: Promise<{ organizationId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
body: ZTeamInput,
|
||||
params: z.object({ organizationId: ZOrganizationIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput: { body, params } }) => {
|
||||
if (!hasOrganizationIdAndAccess(params!.organizationId, authentication, OrganizationAccessType.Write)) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const createTeamResult = await createTeam(body!, authentication.organizationId);
|
||||
if (!createTeamResult.ok) {
|
||||
return handleApiError(request, createTeamResult.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: createTeamResult.data, cors: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { z } from "zod";
|
||||
import { ZTeam } from "@formbricks/database/zod/teams";
|
||||
|
||||
export const ZGetTeamsFilter = ZGetFilter.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
}
|
||||
);
|
||||
|
||||
export type TGetTeamsFilter = z.infer<typeof ZGetTeamsFilter>;
|
||||
|
||||
export const ZTeamInput = ZTeam.pick({
|
||||
name: true,
|
||||
});
|
||||
|
||||
export type TTeamInput = z.infer<typeof ZTeamInput>;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZOrganizationIdSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
ref: "organizationId",
|
||||
description: "The ID of the organization",
|
||||
param: {
|
||||
name: "organizationId",
|
||||
in: "path",
|
||||
},
|
||||
});
|
||||
6
apps/web/modules/api/v2/organizations/lib/openapi.ts
Normal file
6
apps/web/modules/api/v2/organizations/lib/openapi.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const organizationServer = [
|
||||
{
|
||||
url: "https://app.formbricks.com/api/v2/organizations",
|
||||
description: "Formbricks Cloud",
|
||||
},
|
||||
];
|
||||
@@ -1,18 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZRoles } from "@formbricks/database/zod/roles";
|
||||
|
||||
export const getRolesEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getRoles",
|
||||
summary: "Get roles",
|
||||
description: "Gets roles from the database.",
|
||||
requestParams: {},
|
||||
tags: ["Management API > Roles"],
|
||||
tags: ["Roles"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Roles retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(z.string()),
|
||||
schema: makePartialSchema(ZRoles),
|
||||
},
|
||||
},
|
||||
},
|
||||
21
apps/web/modules/api/v2/roles/lib/utils.ts
Normal file
21
apps/web/modules/api/v2/roles/lib/utils.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { OrganizationRole } from "@prisma/client";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getRoles = (): Result<{ data: string[] }, ApiErrorResponseV2> => {
|
||||
try {
|
||||
const roles = Object.values(OrganizationRole);
|
||||
|
||||
// Filter out the billing role if not in Formbricks Cloud
|
||||
const filteredRoles = roles.filter((role) => !(role === "billing" && !IS_FORMBRICKS_CLOUD));
|
||||
return ok({
|
||||
data: filteredRoles,
|
||||
});
|
||||
} catch {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "roles", issue: "Failed to get roles" }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,14 +1,14 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { getRoles } from "@/modules/api/v2/management/roles/lib/roles";
|
||||
import { getRoles } from "@/modules/api/v2/roles/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
handler: async () => {
|
||||
const res = await getRoles();
|
||||
const res = getRoles();
|
||||
|
||||
if (res.ok) {
|
||||
return responses.successResponse(res.data);
|
||||
@@ -1,6 +1,6 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/management/auth/authenticated-api-client";
|
||||
import { upsertBulkContacts } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/contact";
|
||||
import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
|
||||
@@ -377,6 +377,7 @@ export const AddApiKeyModal = ({
|
||||
</div>
|
||||
<div className="flex items-center justify-center py-1">
|
||||
<Switch
|
||||
data-testid={`organization-access-${key}-write`}
|
||||
checked={selectedOrganizationAccess[key].write}
|
||||
onCheckedChange={(newVal) =>
|
||||
setSelectedOrganizationAccessValue(key, "write", newVal)
|
||||
|
||||
@@ -59,33 +59,50 @@ export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => {
|
||||
const hashedKey = hashApiKey(apiKey);
|
||||
return cache(
|
||||
async () => {
|
||||
// Look up the API key in the new structure
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
include: {
|
||||
apiKeyEnvironments: {
|
||||
include: {
|
||||
environment: true,
|
||||
try {
|
||||
// Look up the API key in the new structure
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
include: {
|
||||
apiKeyEnvironments: {
|
||||
include: {
|
||||
environment: {
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!apiKeyData) return null;
|
||||
if (!apiKeyData) return null;
|
||||
|
||||
// Update the last used timestamp
|
||||
await prisma.apiKey.update({
|
||||
where: {
|
||||
id: apiKeyData.id,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// Update the last used timestamp
|
||||
await prisma.apiKey.update({
|
||||
where: {
|
||||
id: apiKeyData.id,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return apiKeyData;
|
||||
return apiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getApiKeyWithPermissions-${apiKey}`],
|
||||
{
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
export const RESPONSES_API_URL = `/api/v2/management/responses`;
|
||||
export const SURVEYS_API_URL = `/api/v1/management/surveys`;
|
||||
export const WEBHOOKS_API_URL = `/api/v2/management/webhooks`;
|
||||
export const ROLES_API_URL = `/api/v2/management/roles`;
|
||||
export const ROLES_API_URL = `/api/v2/roles`;
|
||||
export const ME_API_URL = `/api/v2/me`;
|
||||
|
||||
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
|
||||
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>
|
||||
`/api/v2/organizations/${organizationId}/project-teams`;
|
||||
|
||||
120
apps/web/playwright/api/organization/project-team.spec.ts
Normal file
120
apps/web/playwright/api/organization/project-team.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ME_API_URL, PROJECT_TEAMS_API_URL, TEAMS_API_URL } from "@/playwright/api/constants";
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../../lib/fixtures";
|
||||
import { loginAndGetApiKey } from "../../lib/utils";
|
||||
|
||||
test.describe("API Tests for ProjectTeams", () => {
|
||||
test("Create, Retrieve, Update, and Delete ProjectTeams via API", async ({ page, users, request }) => {
|
||||
let apiKey;
|
||||
try {
|
||||
({ apiKey } = await loginAndGetApiKey(page, users));
|
||||
} catch (error) {
|
||||
logger.error(error, "Error logging in / retrieving API key");
|
||||
throw error;
|
||||
}
|
||||
|
||||
let organizationId, projectId, teamId: string;
|
||||
|
||||
// Get organization ID using the me endpoint
|
||||
await test.step("Get Organization ID", async () => {
|
||||
const response = await request.get(ME_API_URL, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(responseBody.data).toBeTruthy();
|
||||
expect(responseBody.data.organizationId).toBeTruthy();
|
||||
|
||||
organizationId = responseBody.data.organizationId;
|
||||
projectId = responseBody.data.environmentPermissions[0].projectId;
|
||||
});
|
||||
|
||||
// Create a team to use for the project team
|
||||
await test.step("Create Team via API", async () => {
|
||||
const teamBody = {
|
||||
organizationId: organizationId,
|
||||
name: "New Team from API",
|
||||
};
|
||||
|
||||
const response = await request.post(TEAMS_API_URL(organizationId), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
data: teamBody,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody.data.name).toEqual("New Team from API");
|
||||
teamId = responseBody.data.id;
|
||||
});
|
||||
|
||||
await test.step("Create ProjectTeam via API", async () => {
|
||||
const body = {
|
||||
projectId: projectId,
|
||||
teamId: teamId,
|
||||
permission: "readWrite",
|
||||
};
|
||||
const response = await request.post(PROJECT_TEAMS_API_URL(organizationId), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
data: body,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
});
|
||||
|
||||
await test.step("Retrieve ProjectTeams via API", async () => {
|
||||
const queryParams = { teamId: teamId, projectId: projectId };
|
||||
const response = await request.get(PROJECT_TEAMS_API_URL(organizationId), {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
params: queryParams,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(
|
||||
responseBody.data.find((pt: any) => pt.teamId === teamId && pt.projectId === projectId)
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step("Update ProjectTeam by ID via API", async () => {
|
||||
const body = {
|
||||
permission: "read",
|
||||
};
|
||||
const queryParams = { teamId: teamId, projectId: projectId };
|
||||
const response = await request.put(`${PROJECT_TEAMS_API_URL(organizationId)}`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
data: body,
|
||||
params: queryParams,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody.data.permission).toBe("read");
|
||||
});
|
||||
|
||||
await test.step("Delete ProjectTeam via API", async () => {
|
||||
const queryParams = { teamId: teamId, projectId: projectId };
|
||||
const response = await request.delete(`${PROJECT_TEAMS_API_URL(organizationId)}`, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
params: queryParams,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
108
apps/web/playwright/api/organization/team.spec.ts
Normal file
108
apps/web/playwright/api/organization/team.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { ME_API_URL, TEAMS_API_URL } from "@/playwright/api/constants";
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../../lib/fixtures";
|
||||
import { loginAndGetApiKey } from "../../lib/utils";
|
||||
|
||||
test.describe("API Tests for Teams", () => {
|
||||
test("Create, Retrieve, Update, and Delete Teams via API", async ({ page, users, request }) => {
|
||||
let apiKey;
|
||||
try {
|
||||
({ apiKey } = await loginAndGetApiKey(page, users));
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during login and getting API key");
|
||||
throw error;
|
||||
}
|
||||
|
||||
let organizationId, createdTeamId: string;
|
||||
|
||||
// Get organization ID using the me endpoint
|
||||
await test.step("Get Organization ID", async () => {
|
||||
const response = await request.get(ME_API_URL, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(responseBody.data).toBeTruthy();
|
||||
expect(responseBody.data.organizationId).toBeTruthy();
|
||||
|
||||
organizationId = responseBody.data.organizationId;
|
||||
});
|
||||
|
||||
await test.step("Create Team via API", async () => {
|
||||
const teamBody = {
|
||||
organizationId: organizationId,
|
||||
name: "New Team from API",
|
||||
};
|
||||
|
||||
const response = await request.post(TEAMS_API_URL(organizationId), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
data: teamBody,
|
||||
});
|
||||
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody.data.name).toEqual("New Team from API");
|
||||
createdTeamId = responseBody.data.id;
|
||||
});
|
||||
|
||||
await test.step("Retrieve Teams via API", async () => {
|
||||
const queryParams = { limit: 10, skip: 0, sortBy: "createdAt", order: "asc" };
|
||||
|
||||
const response = await request.get(TEAMS_API_URL(organizationId), {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
params: queryParams,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.find((team: any) => team.id === createdTeamId)).toBeTruthy();
|
||||
});
|
||||
|
||||
await test.step("Update Team by ID via API", async () => {
|
||||
const updatedTeamBody = {
|
||||
name: "Updated Team from API",
|
||||
};
|
||||
|
||||
const response = await request.put(`${TEAMS_API_URL(organizationId)}/${createdTeamId}`, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: updatedTeamBody,
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseJson = await response.json();
|
||||
expect(responseJson.data.name).toBe("Updated Team from API");
|
||||
});
|
||||
|
||||
await test.step("Get Team by ID from API", async () => {
|
||||
const response = await request.get(`${TEAMS_API_URL(organizationId)}/${createdTeamId}`, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
const responseBody = await response.json();
|
||||
expect(responseBody.data.id).toEqual(createdTeamId);
|
||||
expect(responseBody.data.name).toEqual("Updated Team from API");
|
||||
});
|
||||
|
||||
await test.step("Delete Team via API", async () => {
|
||||
const response = await request.delete(`${TEAMS_API_URL(organizationId)}/${createdTeamId}`, {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
},
|
||||
});
|
||||
expect(response.ok()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ROLES_API_URL } from "@/playwright/api/constants";
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../../lib/fixtures";
|
||||
import { loginAndGetApiKey } from "../../lib/utils";
|
||||
import { test } from "../lib/fixtures";
|
||||
import { loginAndGetApiKey } from "../lib/utils";
|
||||
|
||||
test.describe("API Tests for Roles", () => {
|
||||
test("Retrieve Roles via API", async ({ page, users, request }) => {
|
||||
@@ -22,6 +22,8 @@ export async function loginAndGetApiKey(page: Page, users: UsersFixture) {
|
||||
await page.getByRole("menuitem", { name: "production" }).click();
|
||||
await page.getByRole("button", { name: "read" }).click();
|
||||
await page.getByRole("menuitem", { name: "manage" }).click();
|
||||
await page.getByTestId("organization-access-accessControl-read").click();
|
||||
await page.getByTestId("organization-access-accessControl-write").click();
|
||||
await page.getByRole("button", { name: "Add API Key" }).click();
|
||||
await page.locator(".copyApiKeyIcon").click();
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { type ApiKey, type ApiKeyEnvironment, ApiKeyPermission } from "@prisma/client";
|
||||
import { type ApiKey, type ApiKeyEnvironment, ApiKeyPermission, EnvironmentType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZOrganizationAccess } from "../../types/api-key";
|
||||
|
||||
@@ -10,6 +10,9 @@ export const ZApiKeyEnvironment = z.object({
|
||||
updatedAt: z.date(),
|
||||
apiKeyId: z.string().cuid2(),
|
||||
environmentId: z.string().cuid2(),
|
||||
projectId: z.string().cuid2(),
|
||||
projectName: z.string(),
|
||||
environmentType: z.nativeEnum(EnvironmentType),
|
||||
permission: ZApiKeyPermission,
|
||||
}) satisfies z.ZodType<ApiKeyEnvironment>;
|
||||
|
||||
@@ -37,3 +40,20 @@ export const ZApiKeyEnvironmentCreateInput = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
permission: ZApiKeyPermission,
|
||||
});
|
||||
|
||||
export const ZApiKeyData = ZApiKey.pick({
|
||||
organizationId: true,
|
||||
organizationAccess: true,
|
||||
}).merge(
|
||||
z.object({
|
||||
environments: z.array(
|
||||
ZApiKeyEnvironment.pick({
|
||||
environmentId: true,
|
||||
environmentType: true,
|
||||
permission: true,
|
||||
projectId: true,
|
||||
projectName: true,
|
||||
})
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
30
packages/database/zod/project-teams.ts
Normal file
30
packages/database/zod/project-teams.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type ProjectTeam, ProjectTeamPermission } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZProjectTeam = z.object({
|
||||
createdAt: z.coerce.date().openapi({
|
||||
description: "The date and time the project tem was created",
|
||||
example: "2021-01-01T00:00:00.000Z",
|
||||
}),
|
||||
updatedAt: z.coerce.date().openapi({
|
||||
description: "The date and time the project team was last updated",
|
||||
example: "2021-01-01T00:00:00.000Z",
|
||||
}),
|
||||
projectId: z.string().cuid2().openapi({
|
||||
description: "The ID of the project",
|
||||
}),
|
||||
teamId: z.string().cuid2().openapi({
|
||||
description: "The ID of the team",
|
||||
}),
|
||||
permission: z.nativeEnum(ProjectTeamPermission).openapi({
|
||||
description: "Level of access granted to the project",
|
||||
}),
|
||||
}) satisfies z.ZodType<ProjectTeam>;
|
||||
|
||||
ZProjectTeam.openapi({
|
||||
ref: "projectTeam",
|
||||
description: "A relationship between a project and a team with associated permissions",
|
||||
});
|
||||
7
packages/database/zod/roles.ts
Normal file
7
packages/database/zod/roles.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZRoles = z.object({
|
||||
data: z.array(
|
||||
z.union([z.literal("owner"), z.literal("manager"), z.literal("member"), z.literal("billing")])
|
||||
),
|
||||
});
|
||||
31
packages/database/zod/teams.ts
Normal file
31
packages/database/zod/teams.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Team } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const ZTeam = z.object({
|
||||
id: z.string().cuid2().openapi({
|
||||
description: "The ID of the team",
|
||||
}),
|
||||
createdAt: z.coerce.date().openapi({
|
||||
description: "The date and time the team was created",
|
||||
example: "2021-01-01T00:00:00.000Z",
|
||||
}),
|
||||
updatedAt: z.coerce.date().openapi({
|
||||
description: "The date and time the team was last updated",
|
||||
example: "2021-01-01T00:00:00.000Z",
|
||||
}),
|
||||
name: z.string().openapi({
|
||||
description: "The name of the team",
|
||||
example: "My team",
|
||||
}),
|
||||
organizationId: z.string().cuid2().openapi({
|
||||
description: "The ID of the organization",
|
||||
}),
|
||||
}) satisfies z.ZodType<Team>;
|
||||
|
||||
ZTeam.openapi({
|
||||
ref: "team",
|
||||
description: "A team",
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
enum OrganizationAccessType {
|
||||
export enum OrganizationAccessType {
|
||||
Read = "read",
|
||||
Write = "write",
|
||||
}
|
||||
|
||||
enum OrganizationAccess {
|
||||
export enum OrganizationAccess {
|
||||
AccessControl = "accessControl",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZOrganizationAccess } from "./api-key";
|
||||
import { ZUser } from "./user";
|
||||
|
||||
export const ZAuthSession = z.object({
|
||||
@@ -8,6 +9,9 @@ export const ZAuthSession = z.object({
|
||||
|
||||
export const ZAPIKeyEnvironmentPermission = z.object({
|
||||
environmentId: z.string(),
|
||||
environmentType: z.nativeEnum(EnvironmentType),
|
||||
projectId: z.string().cuid2(),
|
||||
projectName: z.string(),
|
||||
permission: z.nativeEnum(ApiKeyPermission),
|
||||
});
|
||||
|
||||
@@ -17,8 +21,9 @@ export const ZAuthenticationApiKey = z.object({
|
||||
type: z.literal("apiKey"),
|
||||
environmentPermissions: z.array(ZAPIKeyEnvironmentPermission),
|
||||
hashedApiKey: z.string(),
|
||||
apiKeyId: z.string().optional(),
|
||||
organizationId: z.string().optional(),
|
||||
apiKeyId: z.string(),
|
||||
organizationId: z.string(),
|
||||
organizationAccess: ZOrganizationAccess,
|
||||
});
|
||||
|
||||
export type TAuthSession = z.infer<typeof ZAuthSession>;
|
||||
|
||||
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
@@ -347,7 +347,7 @@ importers:
|
||||
version: 0.0.35(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
'@sentry/nextjs':
|
||||
specifier: 8.52.0
|
||||
version: 8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))
|
||||
version: 8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))
|
||||
'@tailwindcss/forms':
|
||||
specifier: 0.5.9
|
||||
version: 0.5.9(tailwindcss@3.4.16(ts-node@10.9.2(@types/node@22.10.2)(typescript@5.8.2)))
|
||||
@@ -452,13 +452,13 @@ importers:
|
||||
version: 4.0.4
|
||||
next:
|
||||
specifier: 15.2.4
|
||||
version: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
version: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
next-auth:
|
||||
specifier: 4.24.11
|
||||
version: 4.24.11(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nodemailer@6.9.16)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
version: 4.24.11(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nodemailer@6.9.16)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
next-safe-action:
|
||||
specifier: 7.10.2
|
||||
version: 7.10.2(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1)
|
||||
version: 7.10.2(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1)
|
||||
node-fetch:
|
||||
specifier: 3.3.2
|
||||
version: 3.3.2
|
||||
@@ -567,7 +567,7 @@ importers:
|
||||
version: link:../../packages/config-eslint
|
||||
'@neshca/cache-handler':
|
||||
specifier: 1.9.0
|
||||
version: 1.9.0(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)
|
||||
version: 1.9.0(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)
|
||||
'@testing-library/react':
|
||||
specifier: 16.2.0
|
||||
version: 16.2.0(@testing-library/dom@10.4.0)(@types/react-dom@19.0.2(@types/react@19.0.1))(@types/react@19.0.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
@@ -16916,11 +16916,11 @@ snapshots:
|
||||
'@tybys/wasm-util': 0.9.0
|
||||
optional: true
|
||||
|
||||
'@neshca/cache-handler@1.9.0(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)':
|
||||
'@neshca/cache-handler@1.9.0(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(redis@4.7.0)':
|
||||
dependencies:
|
||||
cluster-key-slot: 1.1.2
|
||||
lru-cache: 10.4.3
|
||||
next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
redis: 4.7.0
|
||||
|
||||
'@next/env@15.2.4': {}
|
||||
@@ -19136,7 +19136,7 @@ snapshots:
|
||||
|
||||
'@sentry/core@8.52.0': {}
|
||||
|
||||
'@sentry/nextjs@8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))':
|
||||
'@sentry/nextjs@8.52.0(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.56.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.97.1(esbuild@0.25.2))':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/semantic-conventions': 1.30.0
|
||||
@@ -19149,7 +19149,7 @@ snapshots:
|
||||
'@sentry/vercel-edge': 8.52.0
|
||||
'@sentry/webpack-plugin': 2.22.7(encoding@0.1.13)(webpack@5.97.1(esbuild@0.25.2))
|
||||
chalk: 3.0.0
|
||||
next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
resolve: 1.22.8
|
||||
rollup: 3.29.5
|
||||
stacktrace-parser: 0.1.11
|
||||
@@ -25940,13 +25940,13 @@ snapshots:
|
||||
|
||||
new-github-issue-url@0.2.1: {}
|
||||
|
||||
next-auth@4.24.11(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nodemailer@6.9.16)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
next-auth@4.24.11(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(nodemailer@6.9.16)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.0
|
||||
'@panva/hkdf': 1.2.1
|
||||
cookie: 0.7.2
|
||||
jose: 4.15.9
|
||||
next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
oauth: 0.9.15
|
||||
openid-client: 5.7.1
|
||||
preact: 10.25.2
|
||||
@@ -25974,14 +25974,41 @@ snapshots:
|
||||
optionalDependencies:
|
||||
nodemailer: 6.10.0
|
||||
|
||||
next-safe-action@7.10.2(next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1):
|
||||
next-safe-action@7.10.2(next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(zod@3.24.1):
|
||||
dependencies:
|
||||
next: 15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
next: 15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
optionalDependencies:
|
||||
zod: 3.24.1
|
||||
|
||||
next@15.2.4(@babel/core@7.26.0)(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
'@next/env': 15.2.4
|
||||
'@swc/counter': 0.1.3
|
||||
'@swc/helpers': 0.5.15
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001707
|
||||
postcss: 8.4.31
|
||||
react: 19.0.0
|
||||
react-dom: 19.0.0(react@19.0.0)
|
||||
styled-jsx: 5.1.6(@babel/core@7.26.0)(react@19.0.0)
|
||||
optionalDependencies:
|
||||
'@next/swc-darwin-arm64': 15.2.4
|
||||
'@next/swc-darwin-x64': 15.2.4
|
||||
'@next/swc-linux-arm64-gnu': 15.2.4
|
||||
'@next/swc-linux-arm64-musl': 15.2.4
|
||||
'@next/swc-linux-x64-gnu': 15.2.4
|
||||
'@next/swc-linux-x64-musl': 15.2.4
|
||||
'@next/swc-win32-arm64-msvc': 15.2.4
|
||||
'@next/swc-win32-x64-msvc': 15.2.4
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@playwright/test': 1.51.1
|
||||
sharp: 0.33.5
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- babel-plugin-macros
|
||||
|
||||
next@15.2.4(@opentelemetry/api@1.9.0)(@playwright/test@1.51.1)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||
dependencies:
|
||||
'@next/env': 15.2.4
|
||||
@@ -28403,6 +28430,13 @@ snapshots:
|
||||
dependencies:
|
||||
inline-style-parser: 0.2.4
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.26.0)(react@19.0.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.0.0
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.26.0
|
||||
|
||||
styled-jsx@5.1.6(react@19.0.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
|
||||
@@ -21,5 +21,5 @@ sonar.scm.exclusions.disabled=false
|
||||
sonar.sourceEncoding=UTF-8
|
||||
|
||||
# Coverage
|
||||
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**, **/instrumentation.ts, scripts/merge-client-endpoints.ts
|
||||
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/openapi.ts,**/openapi-document.ts, **/instrumentation.ts, scripts/merge-client-endpoints.ts
|
||||
sonar.coverage.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/*.stories.*,**/mocks/**,**/openapi.ts,**/openapi-document.ts,playwright/**,**/instrumentation.ts,scripts/merge-client-endpoints.ts
|
||||
sonar.cpd.exclusions=**/*.test.*,**/*.spec.*,**/*.mdx,**/*.config.mts,**/*.config.ts,**/constants.ts,**/route.ts,modules/**/types/**,**/openapi.ts,**/openapi-document.ts,**/instrumentation.ts,scripts/merge-client-endpoints.ts
|
||||
Reference in New Issue
Block a user