mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-03 21:59:38 -06:00
Compare commits
27 Commits
feat/bulk-
...
fix-user-i
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c7f92a4d7 | ||
|
|
c653841037 | ||
|
|
ec314c14ea | ||
|
|
c03e60ac0b | ||
|
|
cbf2343143 | ||
|
|
9d9b3ac543 | ||
|
|
591b35a70b | ||
|
|
f0c7b881d3 | ||
|
|
3fd5515db1 | ||
|
|
f32401afd6 | ||
|
|
1b9d91f1e8 | ||
|
|
1f039d707c | ||
|
|
6671d877ad | ||
|
|
2867c95494 | ||
|
|
aa55cec060 | ||
|
|
dfb6c4cd9e | ||
|
|
a9082f66e8 | ||
|
|
bf39b0fbfb | ||
|
|
e347f2179a | ||
|
|
d4f155b6bc | ||
|
|
da001834f5 | ||
|
|
f54352dd82 | ||
|
|
0fba0fae73 | ||
|
|
406ec88515 | ||
|
|
b97957d166 | ||
|
|
655ad6b9e0 | ||
|
|
f5ce42fc2d |
6
.github/workflows/sonarqube.yml
vendored
6
.github/workflows/sonarqube.yml
vendored
@@ -23,10 +23,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
- name: Setup Node.js 22.x
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@fe02b34f77f8bc703788d5817da081398fad5dd2
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
run: |
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@bfd4e558cda28cda6b5defafb9232d191be8c203
|
||||
uses: SonarSource/sonarqube-scan-action@aa494459d7c39c106cc77b166de8b4250a32bb97
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"expo-status-bar": "2.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-native": "0.76.6",
|
||||
"react-native": "0.78.2",
|
||||
"react-native-webview": "13.12.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/demo",
|
||||
"version": "0.1.0",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -12,10 +12,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/js": "workspace:*",
|
||||
"lucide-react": "0.468.0",
|
||||
"next": "15.2.3",
|
||||
"@tailwindcss/forms": "0.5.9",
|
||||
"lucide-react": "0.486.0",
|
||||
"next": "15.2.4",
|
||||
"postcss": "8.5.3",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"react-dom": "19.0.0",
|
||||
"tailwindcss": "3.4.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
|
||||
@@ -11,30 +11,30 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint-plugin-react-refresh": "0.4.16",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0"
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "3.2.2",
|
||||
"@chromatic-com/storybook": "3.2.6",
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@storybook/addon-a11y": "8.4.7",
|
||||
"@storybook/addon-essentials": "8.4.7",
|
||||
"@storybook/addon-interactions": "8.4.7",
|
||||
"@storybook/addon-links": "8.4.7",
|
||||
"@storybook/addon-onboarding": "8.4.7",
|
||||
"@storybook/blocks": "8.4.7",
|
||||
"@storybook/react": "8.4.7",
|
||||
"@storybook/react-vite": "8.4.7",
|
||||
"@storybook/test": "8.4.7",
|
||||
"@typescript-eslint/eslint-plugin": "8.18.0",
|
||||
"@typescript-eslint/parser": "8.18.0",
|
||||
"@storybook/addon-a11y": "8.6.12",
|
||||
"@storybook/addon-essentials": "8.6.12",
|
||||
"@storybook/addon-interactions": "8.6.12",
|
||||
"@storybook/addon-links": "8.6.12",
|
||||
"@storybook/addon-onboarding": "8.6.12",
|
||||
"@storybook/blocks": "8.6.12",
|
||||
"@storybook/react": "8.6.12",
|
||||
"@storybook/react-vite": "8.6.12",
|
||||
"@storybook/test": "8.6.12",
|
||||
"@typescript-eslint/eslint-plugin": "8.29.0",
|
||||
"@typescript-eslint/parser": "8.29.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"esbuild": "0.25.1",
|
||||
"eslint-plugin-storybook": "0.11.1",
|
||||
"esbuild": "0.25.2",
|
||||
"eslint-plugin-storybook": "0.12.0",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "8.4.7",
|
||||
"tsup": "8.3.5",
|
||||
"vite": "6.0.12"
|
||||
"storybook": "8.6.12",
|
||||
"tsup": "8.4.0",
|
||||
"vite": "6.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { APIKeysLoading } from "@/modules/projects/settings/api-keys/loading";
|
||||
|
||||
export default APIKeysLoading;
|
||||
@@ -1,3 +0,0 @@
|
||||
import { APIKeysPage } from "@/modules/projects/settings/api-keys/page";
|
||||
|
||||
export default APIKeysPage;
|
||||
@@ -0,0 +1,6 @@
|
||||
import Loading from "@/modules/organization/settings/api-keys/loading";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
|
||||
export default function LoadingPage() {
|
||||
return <Loading isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { APIKeysPage } from "@/modules/organization/settings/api-keys/page";
|
||||
|
||||
export default APIKeysPage;
|
||||
@@ -54,6 +54,12 @@ export const OrganizationSettingsNavbar = ({
|
||||
hidden: isFormbricksCloud || isPricingDisabled,
|
||||
current: pathname?.includes("/enterprise"),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
href: `/environments/${environmentId}/settings/api-keys`,
|
||||
current: pathname?.includes("/api-keys"),
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
|
||||
@@ -47,12 +47,6 @@ vi.mock("@/app/intercom/IntercomClientWrapper", () => ({
|
||||
vi.mock("@/modules/ui/components/no-mobile-overlay", () => ({
|
||||
NoMobileOverlay: () => <div data-testid="no-mobile-overlay" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
||||
PHProvider: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-testid="ph-provider">{children}</div>
|
||||
),
|
||||
PostHogPageview: () => <div data-testid="ph-pageview" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/toaster-client", () => ({
|
||||
ToasterClient: () => <div data-testid="toaster-client" />,
|
||||
}));
|
||||
@@ -74,8 +68,6 @@ describe("(app) AppLayout", () => {
|
||||
render(element);
|
||||
|
||||
expect(screen.getByTestId("no-mobile-overlay")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-pageview")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mock-intercom-wrapper")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("toaster-client")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-content")).toHaveTextContent("Hello from children");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FormbricksClient } from "@/app/(app)/components/FormbricksClient";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
import { PHProvider, PostHogPageview } from "@/modules/ui/components/post-hog-client";
|
||||
import { ToasterClient } from "@/modules/ui/components/toaster-client";
|
||||
@@ -13,6 +14,11 @@ const AppLayout = async ({ children }) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
// If user account is deactivated, log them out instead of rendering the app
|
||||
if (user?.isActive === false) {
|
||||
return <ClientLogout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
|
||||
178
apps/web/app/api/v1/auth.test.ts
Normal file
178
apps/web/app/api/v1/auth.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getApiKeyWithPermissions", () => {
|
||||
it("should return API key data with permissions when valid key is provided", async () => {
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
hashedKey: "hashed-key",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage" as const,
|
||||
environment: { id: "env-1" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await getApiKeyWithPermissions("test-api-key");
|
||||
|
||||
expect(result).toEqual(mockApiKeyData);
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "api-key-id" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when API key is not found", async () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getApiKeyWithPermissions("invalid-key");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasPermission", () => {
|
||||
const permissions: TAPIKeyEnvironmentPermission[] = [
|
||||
{
|
||||
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", () => {
|
||||
expect(hasPermission(permissions, "env-1", "GET")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-1", "POST")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-1", "DELETE")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle write permission correctly", () => {
|
||||
expect(hasPermission(permissions, "env-2", "GET")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-2", "POST")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-2", "DELETE")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle read permission correctly", () => {
|
||||
expect(hasPermission(permissions, "env-3", "GET")).toBe(true);
|
||||
expect(hasPermission(permissions, "env-3", "POST")).toBe(false);
|
||||
expect(hasPermission(permissions, "env-3", "DELETE")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for non-existent environment", () => {
|
||||
expect(hasPermission(permissions, "env-4", "GET")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
it("should return authentication data for valid API key", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
hashedKey: "hashed-key",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage" as const,
|
||||
environment: {
|
||||
id: "env-1",
|
||||
projectId: "project-1",
|
||||
project: { name: "Project 1" },
|
||||
type: "development",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage",
|
||||
environmentType: "development",
|
||||
projectId: "project-1",
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return null when no API key is provided", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when API key is invalid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,38 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const authenticateRequest = async (request: Request): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (apiKey) {
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (environmentId) {
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId,
|
||||
hashedApiKey,
|
||||
};
|
||||
return authentication;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
if (!apiKey) return null;
|
||||
|
||||
// Get API key with permissions
|
||||
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||
if (!apiKeyData) return null;
|
||||
|
||||
// In the route handlers, we'll do more specific permission checks
|
||||
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
|
||||
if (environmentIds.length === 0) return null;
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
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;
|
||||
};
|
||||
|
||||
export const handleErrorResponse = (error: any): Response => {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { getHash } from "@formbricks/lib/crypto";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
|
||||
const hashedKey = getHash(apiKey);
|
||||
return cache(
|
||||
async () => {
|
||||
validateInputs([apiKey, ZString]);
|
||||
|
||||
if (!apiKey) {
|
||||
throw new InvalidInputError("API key cannot be null or undefined.");
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
throw new ResourceNotFoundError("apiKey", apiKey);
|
||||
}
|
||||
|
||||
return apiKeyData.environmentId;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { deleteActionClass, getActionClass, updateActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
@@ -8,15 +9,20 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
|
||||
const fetchAndAuthorizeActionClass = async (
|
||||
authentication: TAuthenticationApiKey,
|
||||
actionClassId: string
|
||||
actionClassId: string,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE"
|
||||
): Promise<TActionClass | null> => {
|
||||
// Get the action class
|
||||
const actionClass = await getActionClass(actionClassId);
|
||||
if (!actionClass) {
|
||||
return null;
|
||||
}
|
||||
if (actionClass.environmentId !== authentication.environmentId) {
|
||||
|
||||
// Check if API key has permission to access this environment with appropriate permissions
|
||||
if (!hasPermission(authentication.environmentPermissions, actionClass.environmentId, method)) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
return actionClass;
|
||||
};
|
||||
|
||||
@@ -28,7 +34,7 @@ export const GET = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "GET");
|
||||
if (actionClass) {
|
||||
return responses.successResponse(actionClass);
|
||||
}
|
||||
@@ -46,7 +52,7 @@ export const PUT = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "PUT");
|
||||
if (!actionClass) {
|
||||
return responses.notFoundResponse("Action Class", params.actionClassId);
|
||||
}
|
||||
@@ -88,7 +94,7 @@ export const DELETE = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId);
|
||||
const actionClass = await fetchAndAuthorizeActionClass(authentication, params.actionClassId, "DELETE");
|
||||
if (!actionClass) {
|
||||
return responses.notFoundResponse("Action Class", params.actionClassId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getActionClasses } from "./action-classes";
|
||||
|
||||
// Mock the prisma client
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
actionClass: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getActionClasses", () => {
|
||||
const mockEnvironmentIds = ["env1", "env2"];
|
||||
const mockActionClasses = [
|
||||
{
|
||||
id: "action1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action 1",
|
||||
description: "Test Description 1",
|
||||
type: "click",
|
||||
key: "test-key-1",
|
||||
noCodeConfig: {},
|
||||
environmentId: "env1",
|
||||
},
|
||||
{
|
||||
id: "action2",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Action 2",
|
||||
description: "Test Description 2",
|
||||
type: "pageview",
|
||||
key: "test-key-2",
|
||||
noCodeConfig: {},
|
||||
environmentId: "env2",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should successfully fetch action classes for given environment IDs", async () => {
|
||||
// Mock the prisma findMany response
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue(mockActionClasses);
|
||||
|
||||
const result = await getActionClasses(mockEnvironmentIds);
|
||||
|
||||
expect(result).toEqual(mockActionClasses);
|
||||
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: { in: mockEnvironmentIds },
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw DatabaseError when prisma query fails", async () => {
|
||||
// Mock the prisma findMany to throw an error
|
||||
vi.mocked(prisma.actionClass.findMany).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(getActionClasses(mockEnvironmentIds)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
it("should handle empty environment IDs array", async () => {
|
||||
// Mock the prisma findMany response
|
||||
vi.mocked(prisma.actionClass.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getActionClasses([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.actionClass.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
environmentId: { in: [] },
|
||||
},
|
||||
select: expect.any(Object),
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { actionClassCache } from "@formbricks/lib/actionClass/cache";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { TActionClass } from "@formbricks/types/action-classes";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
const selectActionClass = {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
key: true,
|
||||
noCodeConfig: true,
|
||||
environmentId: true,
|
||||
} satisfies Prisma.ActionClassSelect;
|
||||
|
||||
export const getActionClasses = reactCache(
|
||||
async (environmentIds: string[]): Promise<TActionClass[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()]);
|
||||
|
||||
try {
|
||||
return await prisma.actionClass.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
select: selectActionClass,
|
||||
orderBy: {
|
||||
createdAt: "asc",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when fetching actions for environment ${environmentIds}`);
|
||||
}
|
||||
},
|
||||
environmentIds.map((environmentId) => `getActionClasses-management-api-${environmentId}`),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => actionClassCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,16 +1,24 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { createActionClass, getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { createActionClass } from "@formbricks/lib/actionClass/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TActionClass, ZActionClassInput } from "@formbricks/types/action-classes";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { getActionClasses } from "./lib/action-classes";
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const actionClasses: TActionClass[] = await getActionClasses(authentication.environmentId!);
|
||||
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const actionClasses = await getActionClasses(environmentIds);
|
||||
|
||||
return responses.successResponse(actionClasses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -35,6 +43,12 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
|
||||
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
|
||||
|
||||
const environmentId = actionClassInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
@@ -43,10 +57,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
|
||||
const actionClass: TActionClass = await createActionClass(
|
||||
authentication.environmentId!,
|
||||
inputValidation.data
|
||||
);
|
||||
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
|
||||
return responses.successResponse(actionClass);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -12,29 +12,56 @@ export const GET = async () => {
|
||||
hashedKey: hashApiKey(apiKey),
|
||||
},
|
||||
select: {
|
||||
environment: {
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
type: true,
|
||||
project: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
widgetSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
appSetupCompleted: true,
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
return new Response("Not authenticated", {
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
return Response.json(apiKeyData.environment);
|
||||
|
||||
if (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
apiKeyData.apiKeyEnvironments[0].permission === "manage"
|
||||
) {
|
||||
return Response.json({
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.id,
|
||||
type: apiKeyData.apiKeyEnvironments[0].environment.type,
|
||||
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
|
||||
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
|
||||
widgetSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.widgetSetupCompleted,
|
||||
project: {
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
|
||||
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return new Response("You can't use this method with this API key", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const sessionUser = await getSessionUser();
|
||||
if (!sessionUser) {
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { deleteResponse, getResponse, updateResponse } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TResponse, ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
import { ZResponseUpdateInput } from "@formbricks/types/responses";
|
||||
|
||||
const fetchAndValidateResponse = async (authentication: any, responseId: string): Promise<TResponse> => {
|
||||
async function fetchAndAuthorizeResponse(
|
||||
responseId: string,
|
||||
authentication: any,
|
||||
requiredPermission: "GET" | "PUT" | "DELETE"
|
||||
) {
|
||||
const response = await getResponse(responseId);
|
||||
if (!response || !(await canUserAccessResponse(authentication, response))) {
|
||||
throw new Error("Unauthorized");
|
||||
if (!response) {
|
||||
return { error: responses.notFoundResponse("Response", responseId) };
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
const canUserAccessResponse = async (authentication: any, response: TResponse): Promise<boolean> => {
|
||||
const survey = await getSurvey(response.surveyId);
|
||||
if (!survey) return false;
|
||||
|
||||
if (authentication.type === "session") {
|
||||
return await hasUserEnvironmentAccess(authentication.session.user.id, survey.environmentId);
|
||||
} else if (authentication.type === "apiKey") {
|
||||
return survey.environmentId === authentication.environmentId;
|
||||
} else {
|
||||
throw Error("Unknown authentication type");
|
||||
if (!survey) {
|
||||
return { error: responses.notFoundResponse("Survey", response.surveyId, true) };
|
||||
}
|
||||
};
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
|
||||
return { error: responses.unauthorizedResponse() };
|
||||
}
|
||||
|
||||
return { response };
|
||||
}
|
||||
|
||||
export const GET = async (
|
||||
request: Request,
|
||||
@@ -36,11 +37,11 @@ export const GET = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const response = await fetchAndValidateResponse(authentication, params.responseId);
|
||||
if (response) {
|
||||
return responses.successResponse(response);
|
||||
}
|
||||
return responses.notFoundResponse("Response", params.responseId);
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "GET");
|
||||
if (result.error) return result.error;
|
||||
|
||||
return responses.successResponse(result.response);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
@@ -54,10 +55,10 @@ export const DELETE = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const response = await fetchAndValidateResponse(authentication, params.responseId);
|
||||
if (!response) {
|
||||
return responses.notFoundResponse("Response", params.responseId);
|
||||
}
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "DELETE");
|
||||
if (result.error) return result.error;
|
||||
|
||||
const deletedResponse = await deleteResponse(params.responseId);
|
||||
return responses.successResponse(deletedResponse);
|
||||
} catch (error) {
|
||||
@@ -73,7 +74,10 @@ export const PUT = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
await fetchAndValidateResponse(authentication, params.responseId);
|
||||
|
||||
const result = await fetchAndAuthorizeResponse(params.responseId, authentication, "PUT");
|
||||
if (result.error) return result.error;
|
||||
|
||||
let responseUpdate;
|
||||
try {
|
||||
responseUpdate = await request.json();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
@@ -8,11 +10,13 @@ import {
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { getResponseContact } from "@formbricks/lib/response/service";
|
||||
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
|
||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
|
||||
@@ -25,6 +29,7 @@ export const responseSelection = {
|
||||
updatedAt: true,
|
||||
surveyId: true,
|
||||
finished: true,
|
||||
endingId: true,
|
||||
data: true,
|
||||
meta: true,
|
||||
ttc: true,
|
||||
@@ -193,3 +198,53 @@ export const createResponse = async (responseInput: TResponseInput): Promise<TRe
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getResponsesByEnvironmentIds = reactCache(
|
||||
async (environmentIds: string[], limit?: number, offset?: number): Promise<TResponse[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
try {
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
survey: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
orderBy: [
|
||||
{
|
||||
createdAt: "desc",
|
||||
},
|
||||
],
|
||||
take: limit ? limit : undefined,
|
||||
skip: offset ? offset : undefined,
|
||||
});
|
||||
|
||||
const transformedResponses: TResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
contact: getResponseContact(responsePrisma),
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return transformedResponses;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environmentIds.map(
|
||||
(environmentId) => `getResponses-management-api-${environmentId}-${limit}-${offset}`
|
||||
),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => responseCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getResponses, getResponsesByEnvironmentId } from "@formbricks/lib/response/service";
|
||||
import { getResponses } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { createResponse } from "./lib/response";
|
||||
import { createResponse, getResponsesByEnvironmentIds } from "./lib/response";
|
||||
|
||||
export const GET = async (request: NextRequest) => {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
@@ -18,14 +19,26 @@ export const GET = async (request: NextRequest) => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
let environmentResponses: TResponse[] = [];
|
||||
let allResponses: TResponse[] = [];
|
||||
|
||||
if (surveyId) {
|
||||
environmentResponses = await getResponses(surveyId, limit, offset);
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId, true);
|
||||
}
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
const surveyResponses = await getResponses(surveyId, limit, offset);
|
||||
allResponses.push(...surveyResponses);
|
||||
} else {
|
||||
environmentResponses = await getResponsesByEnvironmentId(authentication.environmentId, limit, offset);
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const environmentResponses = await getResponsesByEnvironmentIds(environmentIds, limit, offset);
|
||||
allResponses.push(...environmentResponses);
|
||||
}
|
||||
return responses.successResponse(environmentResponses);
|
||||
return responses.successResponse(allResponses);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
@@ -39,8 +52,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const environmentId = authentication.environmentId;
|
||||
|
||||
let jsonInput;
|
||||
|
||||
try {
|
||||
@@ -50,9 +61,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
// add environmentId to response
|
||||
jsonInput.environmentId = environmentId;
|
||||
|
||||
const inputValidation = ZResponseInput.safeParse(jsonInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
@@ -65,6 +73,12 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
|
||||
const responseInput = inputValidation.data;
|
||||
|
||||
const environmentId = responseInput.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInput.surveyId);
|
||||
if (!survey) {
|
||||
|
||||
@@ -3,21 +3,28 @@ import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/sur
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TSurvey, ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
|
||||
|
||||
const fetchAndAuthorizeSurvey = async (authentication: any, surveyId: string): Promise<TSurvey | null> => {
|
||||
const fetchAndAuthorizeSurvey = async (
|
||||
surveyId: string,
|
||||
authentication: TAuthenticationApiKey,
|
||||
requiredPermission: "GET" | "PUT" | "DELETE"
|
||||
) => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return null;
|
||||
return { error: responses.notFoundResponse("Survey", surveyId) };
|
||||
}
|
||||
if (survey.environmentId !== authentication.environmentId) {
|
||||
throw new Error("Unauthorized");
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, requiredPermission)) {
|
||||
return { error: responses.unauthorizedResponse() };
|
||||
}
|
||||
return survey;
|
||||
|
||||
return { survey };
|
||||
};
|
||||
|
||||
export const GET = async (
|
||||
@@ -28,11 +35,9 @@ export const GET = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
||||
if (survey) {
|
||||
return responses.successResponse(survey);
|
||||
}
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "GET");
|
||||
if (result.error) return result.error;
|
||||
return responses.successResponse(result.survey);
|
||||
} catch (error) {
|
||||
return handleErrorResponse(error);
|
||||
}
|
||||
@@ -46,10 +51,8 @@ export const DELETE = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
}
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "DELETE");
|
||||
if (result.error) return result.error;
|
||||
const deletedSurvey = await deleteSurvey(params.surveyId);
|
||||
return responses.successResponse(deletedSurvey);
|
||||
} catch (error) {
|
||||
@@ -65,13 +68,10 @@ export const PUT = async (
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
const result = await fetchAndAuthorizeSurvey(params.surveyId, authentication, "PUT");
|
||||
if (result.error) return result.error;
|
||||
|
||||
const survey = await fetchAndAuthorizeSurvey(authentication, params.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
|
||||
const organization = await getOrganizationByEnvironmentId(result.survey.environmentId);
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("Organization", null);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export const PUT = async (
|
||||
}
|
||||
|
||||
const inputValidation = ZSurveyUpdateInput.safeParse({
|
||||
...survey,
|
||||
...result.survey,
|
||||
...surveyUpdate,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { authenticateRequest, handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { getSurveyDomain } from "@formbricks/lib/getSurveyUrl";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
@@ -17,8 +18,8 @@ export const GET = async (
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", params.surveyId);
|
||||
}
|
||||
if (survey.environmentId !== authentication.environmentId) {
|
||||
throw new Error("Unauthorized");
|
||||
if (!hasPermission(authentication.environmentPermissions, survey.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (!survey.singleUse || !survey.singleUse.enabled) {
|
||||
|
||||
48
apps/web/app/api/v1/management/surveys/lib/surveys.ts
Normal file
48
apps/web/app/api/v1/management/surveys/lib/surveys.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { selectSurvey } from "@formbricks/lib/survey/service";
|
||||
import { transformPrismaSurvey } from "@formbricks/lib/survey/utils";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getSurveys = reactCache(
|
||||
async (environmentIds: string[], limit?: number, offset?: number): Promise<TSurvey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentIds, ZId.array()], [limit, ZOptionalNumber], [offset, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error, "Error getting surveys");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
environmentIds.map((environmentId) => `getSurveys-management-api-${environmentId}-${limit}-${offset}`),
|
||||
{
|
||||
tags: environmentIds.map((environmentId) => surveyCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -2,12 +2,14 @@ import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { getSurveyFollowUpsPermission } from "@/modules/survey/follow-ups/lib/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { createSurvey, getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { createSurvey } from "@formbricks/lib/survey/service";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { ZSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import { ZSurveyCreateInputWithEnvironmentId } from "@formbricks/types/surveys/types";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = async (request: Request) => {
|
||||
try {
|
||||
@@ -18,7 +20,11 @@ export const GET = async (request: Request) => {
|
||||
const limit = searchParams.has("limit") ? Number(searchParams.get("limit")) : undefined;
|
||||
const offset = searchParams.has("offset") ? Number(searchParams.get("offset")) : undefined;
|
||||
|
||||
const surveys = await getSurveys(authentication.environmentId!, limit, offset);
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const surveys = await getSurveys(environmentIds, limit, offset);
|
||||
|
||||
return responses.successResponse(surveys);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -33,11 +39,6 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) return responses.notAuthenticatedResponse();
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(authentication.environmentId);
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("Organization", null);
|
||||
}
|
||||
|
||||
let surveyInput;
|
||||
try {
|
||||
surveyInput = await request.json();
|
||||
@@ -45,8 +46,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
logger.error({ error, url: request.url }, "Error parsing JSON");
|
||||
return responses.badRequestResponse("Malformed JSON input, please check your request body");
|
||||
}
|
||||
|
||||
const inputValidation = ZSurveyCreateInput.safeParse(surveyInput);
|
||||
const inputValidation = ZSurveyCreateInputWithEnvironmentId.safeParse(surveyInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
@@ -56,8 +56,18 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
);
|
||||
}
|
||||
|
||||
const environmentId = authentication.environmentId;
|
||||
const surveyData = { ...inputValidation.data, environmentId: undefined };
|
||||
const environmentId = inputValidation.data.environmentId;
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
return responses.notFoundResponse("Organization", null);
|
||||
}
|
||||
|
||||
const surveyData = { ...inputValidation.data, environmentId };
|
||||
|
||||
if (surveyData.followUps?.length) {
|
||||
const isSurveyFollowUpsEnabled = await getSurveyFollowUpsPermission(organization.billing.plan);
|
||||
@@ -73,7 +83,7 @@ export const POST = async (request: Request): Promise<Response> => {
|
||||
}
|
||||
}
|
||||
|
||||
const survey = await createSurvey(environmentId, surveyData);
|
||||
const survey = await createSurvey(environmentId, { ...surveyData, environmentId: undefined });
|
||||
return responses.successResponse(survey);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { deleteWebhook, getWebhook } from "@/app/api/v1/webhooks/[webhookId]/lib/webhook";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { headers } from "next/headers";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
export const GET = async (_: Request, props: { params: Promise<{ webhookId: string }> }) => {
|
||||
export const GET = async (request: Request, props: { params: Promise<{ webhookId: string }> }) => {
|
||||
const params = await props.params;
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
@@ -21,7 +22,7 @@ export const GET = async (_: Request, props: { params: Promise<{ webhookId: stri
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (webhook.environmentId !== environmentId) {
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "GET")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
return responses.successResponse(webhook);
|
||||
@@ -34,8 +35,8 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ webhoo
|
||||
if (!webhook) {
|
||||
return responses.notFoundResponse("Webhook", params.webhookId);
|
||||
}
|
||||
if (webhook.environmentId !== environmentId) {
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.environmentId, "DELETE")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,17 +8,20 @@ import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([environmentId, ZId], [webhookInput, ZWebhookInput]);
|
||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
|
||||
try {
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
...webhookInput,
|
||||
url: webhookInput.url,
|
||||
name: webhookInput.name,
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
triggers: webhookInput.triggers || [],
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
id: webhookInput.environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -37,22 +40,24 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
|
||||
}
|
||||
|
||||
if (!(error instanceof InvalidInputError)) {
|
||||
throw new DatabaseError(`Database error when creating webhook for environment ${environmentId}`);
|
||||
throw new DatabaseError(
|
||||
`Database error when creating webhook for environment ${webhookInput.environmentId}`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getWebhooks = (environmentId: string, page?: number): Promise<Webhook[]> =>
|
||||
export const getWebhooks = (environmentIds: string[], page?: number): Promise<Webhook[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId], [page, ZOptionalNumber]);
|
||||
validateInputs([environmentIds, ZId.array()], [page, ZOptionalNumber]);
|
||||
|
||||
try {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
take: page ? ITEMS_PER_PAGE : undefined,
|
||||
skip: page ? ITEMS_PER_PAGE * (page - 1) : undefined,
|
||||
@@ -66,8 +71,8 @@ export const getWebhooks = (environmentId: string, page?: number): Promise<Webho
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getWebhooks-${environmentId}-${page}`],
|
||||
environmentIds.map((environmentId) => `getWebhooks-${environmentId}-${page}`),
|
||||
{
|
||||
tags: [webhookCache.tag.byEnvironmentId(environmentId)],
|
||||
tags: environmentIds.map((environmentId) => webhookCache.tag.byEnvironmentId(environmentId)),
|
||||
}
|
||||
)();
|
||||
|
||||
@@ -1,42 +1,33 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/app/api/v1/lib/api-key";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { createWebhook, getWebhooks } from "@/app/api/v1/webhooks/lib/webhook";
|
||||
import { ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { headers } from "next/headers";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
|
||||
export const GET = async () => {
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (!apiKey) {
|
||||
export const GET = async (request: Request) => {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
// get webhooks from database
|
||||
try {
|
||||
const webhooks = await getWebhooks(environmentId);
|
||||
return Response.json({ data: webhooks });
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
const webhooks = await getWebhooks(environmentIds);
|
||||
return responses.successResponse(webhooks);
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
if (!apiKey) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const environmentId = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentId) {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
const webhookInput = await request.json();
|
||||
@@ -50,9 +41,19 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
}
|
||||
|
||||
const environmentId = inputValidation.data.environmentId;
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Environment ID is required");
|
||||
}
|
||||
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
// add webhook to database
|
||||
try {
|
||||
const webhook = await createWebhook(environmentId, inputValidation.data);
|
||||
const webhook = await createWebhook(inputValidation.data);
|
||||
return responses.successResponse(webhook);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export const ZWebhookInput = ZWebhook.partial({
|
||||
surveyIds: true,
|
||||
triggers: true,
|
||||
url: true,
|
||||
environmentId: true,
|
||||
});
|
||||
|
||||
export type TWebhookInput = z.infer<typeof ZWebhookInput>;
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, PATCH, POST } from "@/modules/api/v2/organizations/[organizationId]/users/route";
|
||||
|
||||
export { GET, POST, PATCH };
|
||||
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 };
|
||||
@@ -40,10 +40,6 @@ vi.mock("@/tolgee/server", () => ({
|
||||
getTolgee: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@vercel/speed-insights/next", () => ({
|
||||
SpeedInsights: () => <div data-testid="speed-insights">SpeedInsights</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/post-hog-client", () => ({
|
||||
PHProvider: ({ children, posthogEnabled }: { children: React.ReactNode; posthogEnabled: boolean }) => (
|
||||
<div data-testid="ph-provider">
|
||||
@@ -101,11 +97,6 @@ describe("RootLayout", () => {
|
||||
const element = await RootLayout({ children });
|
||||
render(element);
|
||||
|
||||
// log env vercel
|
||||
console.log("vercel", process.env.VERCEL);
|
||||
|
||||
expect(screen.getByTestId("speed-insights")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("ph-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("tolgee-next-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("sentry-provider")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child")).toHaveTextContent("Child Content");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { SentryProvider } from "@/app/sentry/SentryProvider";
|
||||
import { PHProvider } from "@/modules/ui/components/post-hog-client";
|
||||
import { TolgeeNextProvider } from "@/tolgee/client";
|
||||
import { getLocale } from "@/tolgee/language";
|
||||
import { getTolgee } from "@/tolgee/server";
|
||||
import { TolgeeStaticData } from "@tolgee/react";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { Metadata } from "next";
|
||||
import React from "react";
|
||||
import { IS_POSTHOG_CONFIGURED, SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
import { SENTRY_DSN } from "@formbricks/lib/constants";
|
||||
import "../modules/ui/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -27,13 +25,10 @@ const RootLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang={locale} translate="no">
|
||||
<body className="flex h-dvh flex-col transition-all ease-in-out">
|
||||
{process.env.VERCEL === "1" && <SpeedInsights sampleRate={0.1} />}
|
||||
<SentryProvider sentryDsn={SENTRY_DSN}>
|
||||
<PHProvider posthogEnabled={IS_POSTHOG_CONFIGURED}>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
</PHProvider>
|
||||
<TolgeeNextProvider language={locale} staticData={staticData as unknown as TolgeeStaticData}>
|
||||
{children}
|
||||
</TolgeeNextProvider>
|
||||
</SentryProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const getFile = async (
|
||||
headers: {
|
||||
"Content-Type": metaData.contentType,
|
||||
"Content-Disposition": "attachment",
|
||||
"Cache-Control": "public, max-age=1200, s-maxage=1200, stale-while-revalidate=300",
|
||||
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||
Vary: "Accept-Encoding",
|
||||
},
|
||||
});
|
||||
@@ -35,10 +35,7 @@ export const getFile = async (
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: signedUrl,
|
||||
"Cache-Control":
|
||||
accessType === "public"
|
||||
? `public, max-age=3600, s-maxage=3600, stale-while-revalidate=300`
|
||||
: `public, max-age=600, s-maxage=3600, stale-while-revalidate=300`,
|
||||
"Cache-Control": "public, max-age=300, s-maxage=300, stale-while-revalidate=300",
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
|
||||
18
apps/web/lib/cache/api-key.ts
vendored
18
apps/web/lib/cache/api-key.ts
vendored
@@ -2,8 +2,8 @@ import { revalidateTag } from "next/cache";
|
||||
|
||||
interface RevalidateProps {
|
||||
id?: string;
|
||||
environmentId?: string;
|
||||
hashedKey?: string;
|
||||
organizationId?: string;
|
||||
}
|
||||
|
||||
export const apiKeyCache = {
|
||||
@@ -11,24 +11,24 @@ export const apiKeyCache = {
|
||||
byId(id: string) {
|
||||
return `apiKeys-${id}`;
|
||||
},
|
||||
byEnvironmentId(environmentId: string) {
|
||||
return `environments-${environmentId}-apiKeys`;
|
||||
},
|
||||
byHashedKey(hashedKey: string) {
|
||||
return `apiKeys-${hashedKey}-apiKey`;
|
||||
},
|
||||
byOrganizationId(organizationId: string) {
|
||||
return `organizations-${organizationId}-apiKeys`;
|
||||
},
|
||||
},
|
||||
revalidate({ id, environmentId, hashedKey }: RevalidateProps): void {
|
||||
revalidate({ id, hashedKey, organizationId }: RevalidateProps): void {
|
||||
if (id) {
|
||||
revalidateTag(this.tag.byId(id));
|
||||
}
|
||||
|
||||
if (environmentId) {
|
||||
revalidateTag(this.tag.byEnvironmentId(environmentId));
|
||||
}
|
||||
|
||||
if (hashedKey) {
|
||||
revalidateTag(this.tag.byHashedKey(hashedKey));
|
||||
}
|
||||
|
||||
if (organizationId) {
|
||||
revalidateTag(this.tag.byOrganizationId(organizationId));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -155,7 +155,7 @@ export const getOrganizationIdFromApiKeyId = async (apiKeyId: string) => {
|
||||
throw new ResourceNotFoundError("apiKey", apiKeyId);
|
||||
}
|
||||
|
||||
return await getOrganizationIdFromEnvironmentId(apiKeyFromServer.environmentId);
|
||||
return apiKeyFromServer.organizationId;
|
||||
};
|
||||
|
||||
export const getOrganizationIdFromInviteId = async (inviteId: string) => {
|
||||
@@ -240,15 +240,6 @@ export const getProjectIdFromSegmentId = async (segmentId: string) => {
|
||||
return await getProjectIdFromEnvironmentId(segment.environmentId);
|
||||
};
|
||||
|
||||
export const getProjectIdFromApiKeyId = async (apiKeyId: string) => {
|
||||
const apiKey = await getApiKey(apiKeyId);
|
||||
if (!apiKey) {
|
||||
throw new ResourceNotFoundError("apiKey", apiKeyId);
|
||||
}
|
||||
|
||||
return await getProjectIdFromEnvironmentId(apiKey.environmentId);
|
||||
};
|
||||
|
||||
export const getProjectIdFromActionClassId = async (actionClassId: string) => {
|
||||
const actionClass = await getActionClass(actionClassId);
|
||||
if (!actionClass) {
|
||||
|
||||
@@ -51,7 +51,7 @@ export const getActionClass = reactCache(
|
||||
);
|
||||
|
||||
export const getApiKey = reactCache(
|
||||
async (apiKeyId: string): Promise<{ environmentId: string } | null> =>
|
||||
async (apiKeyId: string): Promise<{ organizationId: string } | null> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([apiKeyId, ZString]);
|
||||
@@ -66,7 +66,7 @@ export const getApiKey = reactCache(
|
||||
id: apiKeyId,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
organizationId: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
34
apps/web/modules/api/v2/auth/authenticate-request.ts
Normal file
34
apps/web/modules/api/v2/auth/authenticate-request.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: Request
|
||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
if (!apiKey) return err({ type: "unauthorized" });
|
||||
|
||||
const apiKeyData = await getApiKeyWithPermissions(apiKey);
|
||||
|
||||
if (!apiKeyData) return err({ type: "unauthorized" });
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
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";
|
||||
114
apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts
Normal file
114
apps/web/modules/api/v2/auth/tests/authenticate-request.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authenticateRequest } from "../authenticate-request";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
it("should return authentication data if apiKey is valid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
hashedKey: "hashed-api-key",
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-id-1",
|
||||
permission: "manage",
|
||||
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",
|
||||
projectId: "project-id-2",
|
||||
type: "production",
|
||||
project: { name: "Project 2" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [
|
||||
{
|
||||
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",
|
||||
organizationId: "org-id",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should return unauthorized error if apiKey is not found", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return unauthorized error if apiKey is missing", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -232,7 +235,7 @@ const internalServerErrorResponse = ({
|
||||
const successResponse = ({
|
||||
data,
|
||||
meta,
|
||||
cors = false,
|
||||
cors = true,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
data: Object;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: Request
|
||||
): Promise<Result<TAuthenticationApiKey, ApiErrorResponseV2>> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
const environmentIdResult = await getEnvironmentIdFromApiKey(apiKey);
|
||||
if (!environmentIdResult.ok) {
|
||||
return err(environmentIdResult.error);
|
||||
}
|
||||
const environmentId = environmentIdResult.data;
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
if (environmentId) {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId,
|
||||
hashedApiKey,
|
||||
};
|
||||
return ok(authentication);
|
||||
}
|
||||
return err({
|
||||
type: "forbidden",
|
||||
});
|
||||
}
|
||||
return err({
|
||||
type: "unauthorized",
|
||||
});
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const checkAuthorization = ({
|
||||
authentication,
|
||||
environmentId,
|
||||
}: {
|
||||
authentication: TAuthenticationApiKey;
|
||||
environmentId: string;
|
||||
}): Result<void, ApiErrorResponseV2> => {
|
||||
if (authentication.type === "apiKey" && authentication.environmentId !== environmentId) {
|
||||
return err({
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
return okVoid();
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { authenticateRequest } from "../authenticate-request";
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/api-key", () => ({
|
||||
getEnvironmentIdFromApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
it("should return authentication data if apiKey is valid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok("env-id"));
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("should return forbidden error if environmentId is not found", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(err({ type: "forbidden" }));
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return forbidden error if environmentId is empty", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
vi.mocked(getEnvironmentIdFromApiKey).mockResolvedValue(ok(""));
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "forbidden" });
|
||||
}
|
||||
});
|
||||
|
||||
it("should return unauthorized error if apiKey is missing", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { checkAuthorization } from "../check-authorization";
|
||||
|
||||
describe("checkAuthorization", () => {
|
||||
it("should return ok if authentication is valid", () => {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
};
|
||||
const result = checkAuthorization({ authentication, environmentId: "env-id" });
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("should return unauthorized error if environmentId does not match", () => {
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
};
|
||||
const result = checkAuthorization({ authentication, environmentId: "different-env-id" });
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { apiKeyCache } from "@/lib/cache/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string) => {
|
||||
const hashedKey = hashApiKey(apiKey);
|
||||
return cache(
|
||||
async (): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
if (!apiKey) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
details: [{ field: "apiKey", issue: "API key cannot be null or undefined." }],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
return err({ type: "not_found", details: [{ field: "apiKey", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(apiKeyData.environmentId);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "apiKey", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`management-api-getEnvironmentIdFromApiKey-${hashedKey}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { apiKey, environmentId } from "./__mocks__/api-key.mock";
|
||||
import { getEnvironmentIdFromApiKey } from "@/modules/api/v2/management/lib/api-key";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn((input: string) => `hashed-${input}`),
|
||||
}));
|
||||
|
||||
describe("getEnvironmentIdFromApiKey", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns a bad_request error if apiKey is empty", async () => {
|
||||
const result = await getEnvironmentIdFromApiKey("");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("bad_request");
|
||||
expect(result.error.details).toEqual([
|
||||
{ field: "apiKey", issue: "API key cannot be null or undefined." },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns a not_found error when no apiKey record is found in the database", async () => {
|
||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { hashedKey: `hashed-${apiKey}` },
|
||||
select: { environmentId: true },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("not_found");
|
||||
expect(result.error.details).toEqual([{ field: "apiKey", issue: "not found" }]);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns ok with environmentId when a valid apiKey record is found", async () => {
|
||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ environmentId });
|
||||
|
||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { hashedKey: `hashed-${apiKey}` },
|
||||
select: { environmentId: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe(environmentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns internal_server_error when an exception occurs during the database lookup", async () => {
|
||||
vi.mocked(hashApiKey).mockImplementation((input: string) => `hashed-${input}`);
|
||||
vi.mocked(prisma.apiKey.findUnique).mockRejectedValue(new Error("Database failure"));
|
||||
|
||||
const result = await getEnvironmentIdFromApiKey(apiKey);
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { hashedKey: `hashed-${apiKey}` },
|
||||
select: { environmentId: true },
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
expect(result.error.details).toEqual([{ field: "apiKey", issue: "Database failure" }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,12 @@ 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
|
||||
| Prisma.UserFindManyArgs;
|
||||
|
||||
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params || {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { ZResponseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
@@ -11,7 +11,7 @@ export const getResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Gets a response from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
id: ZResponseIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Responses"],
|
||||
@@ -34,7 +34,7 @@ export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
id: ZResponseIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
@@ -56,7 +56,7 @@ export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
id: ZResponseIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { deleteDisplay } from "@/modules/api/v2/management/responses/[responseId]/lib/display";
|
||||
import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[responseId]/lib/survey";
|
||||
import { findAndDeleteUploadedFilesInResponse } from "@/modules/api/v2/management/responses/[responseId]/lib/utils";
|
||||
import { responseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { ZResponseUpdateSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Response } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
@@ -98,7 +98,7 @@ export const deleteResponse = async (responseId: string): Promise<Result<Respons
|
||||
|
||||
export const updateResponse = async (
|
||||
responseId: string,
|
||||
responseInput: z.infer<typeof responseUpdateSchema>
|
||||
responseInput: z.infer<typeof ZResponseUpdateSchema>
|
||||
): Promise<Result<Response, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedResponse = await prisma.response.update({
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
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 { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
import { getEnvironmentId } from "@/modules/api/v2/management/lib/helper";
|
||||
import {
|
||||
deleteResponse,
|
||||
getResponse,
|
||||
updateResponse,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/response";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { responseIdSchema, responseUpdateSchema } from "./types/responses";
|
||||
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
|
||||
|
||||
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
params: z.object({ responseId: ZResponseIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -33,13 +33,10 @@ export const GET = async (request: Request, props: { params: Promise<{ responseI
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await getResponse(params.responseId);
|
||||
@@ -55,7 +52,7 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
params: z.object({ responseId: ZResponseIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -73,13 +70,10 @@ export const DELETE = async (request: Request, props: { params: Promise<{ respon
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "DELETE")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await deleteResponse(params.responseId);
|
||||
@@ -97,8 +91,8 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
request,
|
||||
externalParams: props.params,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
body: responseUpdateSchema,
|
||||
params: z.object({ responseId: ZResponseIdSchema }),
|
||||
body: ZResponseUpdateSchema,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { body, params } = parsedInput;
|
||||
@@ -115,13 +109,10 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentIdResult.data, "PUT")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const response = await updateResponse(params.responseId, body);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const responseIdSchema = z
|
||||
export const ZResponseIdSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
@@ -16,7 +16,7 @@ export const responseIdSchema = z
|
||||
},
|
||||
});
|
||||
|
||||
export const responseUpdateSchema = ZResponse.omit({
|
||||
export const ZResponseUpdateSchema = ZResponse.omit({
|
||||
id: true,
|
||||
surveyId: true,
|
||||
}).openapi({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -14,7 +13,7 @@ export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
summary: "Get responses",
|
||||
description: "Gets responses from the database.",
|
||||
requestParams: {
|
||||
query: ZGetResponsesFilter.sourceType().required(),
|
||||
query: ZGetResponsesFilter.sourceType(),
|
||||
},
|
||||
tags: ["Management API > Responses"],
|
||||
responses: {
|
||||
@@ -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)),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -130,16 +130,18 @@ export const createResponse = async (
|
||||
};
|
||||
|
||||
export const getResponses = async (
|
||||
environmentId: string,
|
||||
environmentIds: string[],
|
||||
params: TGetResponsesFilter
|
||||
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const query = getResponsesQuery(environmentIds, params);
|
||||
|
||||
const [responses, count] = await prisma.$transaction([
|
||||
prisma.response.findMany({
|
||||
...getResponsesQuery(environmentId, params),
|
||||
...query,
|
||||
}),
|
||||
prisma.response.count({
|
||||
where: getResponsesQuery(environmentId, params).where,
|
||||
where: query.where,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
|
||||
describe("getResponsesQuery", () => {
|
||||
it("adds surveyId to where clause if provided", () => {
|
||||
const result = getResponsesQuery("env-id", { surveyId: "survey123" } as TGetResponsesFilter);
|
||||
const result = getResponsesQuery(["env-id"], { surveyId: "survey123" } as TGetResponsesFilter);
|
||||
expect(result?.where?.surveyId).toBe("survey123");
|
||||
});
|
||||
|
||||
it("adds contactId to where clause if provided", () => {
|
||||
const result = getResponsesQuery("env-id", { contactId: "contact123" } as TGetResponsesFilter);
|
||||
const result = getResponsesQuery(["env-id"], { contactId: "contact123" } as TGetResponsesFilter);
|
||||
expect(result?.where?.contactId).toBe("contact123");
|
||||
});
|
||||
|
||||
@@ -24,12 +24,12 @@ describe("getResponsesQuery", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValueOnce({ someFilter: true } as any);
|
||||
vi.mocked(buildCommonFilterQuery).mockReturnValueOnce({ where: { combined: true } as any });
|
||||
|
||||
const result = getResponsesQuery("env-id", { surveyId: "test" } as TGetResponsesFilter);
|
||||
const result = getResponsesQuery(["env-id"], { surveyId: "test" } as TGetResponsesFilter);
|
||||
expect(pickCommonFilter).toHaveBeenCalledWith({ surveyId: "test" });
|
||||
expect(buildCommonFilterQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining<Prisma.ResponseFindManyArgs>({
|
||||
where: {
|
||||
survey: { environmentId: "env-id" },
|
||||
survey: { environmentId: { in: ["env-id"] } },
|
||||
surveyId: "test",
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -2,11 +2,11 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/manag
|
||||
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const getResponsesQuery = (environmentId: string, params?: TGetResponsesFilter) => {
|
||||
export const getResponsesQuery = (environmentIds: string[], params?: TGetResponsesFilter) => {
|
||||
let query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
survey: {
|
||||
environmentId,
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
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";
|
||||
import { Response } from "@prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { createResponse, getResponses } from "./lib/response";
|
||||
|
||||
@@ -23,15 +24,20 @@ export const GET = async (request: NextRequest) =>
|
||||
});
|
||||
}
|
||||
|
||||
const environmentId = authentication.environmentId;
|
||||
const environmentIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const res = await getResponses(environmentId, query);
|
||||
const environmentResponses: Response[] = [];
|
||||
const res = await getResponses(environmentIds, query);
|
||||
|
||||
if (res.ok) {
|
||||
return responses.successResponse(res.data);
|
||||
if (!res.ok) {
|
||||
return handleApiError(request, res.error);
|
||||
}
|
||||
|
||||
return handleApiError(request, res.error);
|
||||
environmentResponses.push(...res.data.data);
|
||||
|
||||
return responses.successResponse({ data: environmentResponses });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -59,13 +65,10 @@ export const POST = async (request: Request) =>
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
// if there is a createdAt but no updatedAt, set updatedAt to createdAt
|
||||
@@ -78,6 +81,6 @@ export const POST = async (request: Request) =>
|
||||
return handleApiError(request, createResponseResult.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: createResponseResult.data, cors: true });
|
||||
return responses.successResponse({ data: createResponseResult.data });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,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,12 +1,12 @@
|
||||
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 { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
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";
|
||||
import { getSurvey } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/surveys";
|
||||
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
|
||||
@@ -43,13 +43,10 @@ export const GET = async (
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
});
|
||||
}
|
||||
|
||||
const surveyResult = await getSurvey(params.surveyId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { webhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookIdSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
|
||||
import { z } from "zod";
|
||||
@@ -11,7 +11,7 @@ export const getWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
description: "Gets a webhook from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: ZWebhookIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Webhooks"],
|
||||
@@ -34,7 +34,7 @@ export const deleteWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: ZWebhookIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
@@ -56,7 +56,7 @@ export const updateWebhookEndpoint: ZodOpenApiOperationObject = {
|
||||
tags: ["Management API > Webhooks"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
webhookId: webhookIdSchema,
|
||||
id: ZWebhookIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
mockedPrismaWebhookUpdateReturn,
|
||||
prismaNotFoundError,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/tests/mocks/webhook.mock";
|
||||
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -61,7 +61,7 @@ describe("getWebhook", () => {
|
||||
});
|
||||
|
||||
describe("updateWebhook", () => {
|
||||
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof webhookUpdateSchema>;
|
||||
const mockedWebhookUpdateReturn = { url: "https://example.com" } as z.infer<typeof ZWebhookUpdateSchema>;
|
||||
|
||||
test("returns ok on successful update", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { webhookCache } from "@/lib/cache/webhook";
|
||||
import { webhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
@@ -42,7 +42,7 @@ export const getWebhook = async (webhookId: string) =>
|
||||
|
||||
export const updateWebhook = async (
|
||||
webhookId: string,
|
||||
webhookInput: z.infer<typeof webhookUpdateSchema>
|
||||
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
|
||||
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedWebhook = await prisma.webhook.update({
|
||||
|
||||
@@ -1,7 +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 { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
import { getEnvironmentIdFromSurveyIds } from "@/modules/api/v2/management/lib/helper";
|
||||
import {
|
||||
deleteWebhook,
|
||||
@@ -9,9 +8,10 @@ import {
|
||||
updateWebhook,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/lib/webhook";
|
||||
import {
|
||||
webhookIdSchema,
|
||||
webhookUpdateSchema,
|
||||
ZWebhookIdSchema,
|
||||
ZWebhookUpdateSchema,
|
||||
} from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -19,7 +19,7 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
params: z.object({ webhookId: ZWebhookIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -38,13 +38,11 @@ export const GET = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "GET")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse(webhook);
|
||||
@@ -55,8 +53,8 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
body: webhookUpdateSchema,
|
||||
params: z.object({ webhookId: ZWebhookIdSchema }),
|
||||
body: ZWebhookUpdateSchema,
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -83,14 +81,11 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
// check webhook environment against the api key environment
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "PUT")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
// check if webhook environment matches the surveys environment
|
||||
@@ -117,7 +112,7 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ webhookId: webhookIdSchema }),
|
||||
params: z.object({ webhookId: ZWebhookIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
@@ -136,13 +131,11 @@ export const DELETE = async (request: NextRequest, props: { params: Promise<{ we
|
||||
return handleApiError(request, webhook.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: webhook.ok ? webhook.data.environmentId : "",
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
if (!hasPermission(authentication.environmentPermissions, webhook.data.environmentId, "DELETE")) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "webhook", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
const deletedWebhook = await deleteWebhook(params.webhookId);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const webhookIdSchema = z
|
||||
export const ZWebhookIdSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
@@ -16,7 +16,7 @@ export const webhookIdSchema = z
|
||||
},
|
||||
});
|
||||
|
||||
export const webhookUpdateSchema = ZWebhook.omit({
|
||||
export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -14,7 +13,7 @@ export const getWebhooksEndpoint: ZodOpenApiOperationObject = {
|
||||
summary: "Get webhooks",
|
||||
description: "Gets webhooks from the database.",
|
||||
requestParams: {
|
||||
query: ZGetWebhooksFilter.sourceType().required(),
|
||||
query: ZGetWebhooksFilter.sourceType(),
|
||||
},
|
||||
tags: ["Management API > Webhooks"],
|
||||
responses: {
|
||||
@@ -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,
|
||||
|
||||
@@ -13,24 +13,24 @@ describe("getWebhooksQuery", () => {
|
||||
|
||||
it("adds surveyIds condition when provided", () => {
|
||||
const params = { surveyIds: ["survey1"] } as TGetWebhooksFilter;
|
||||
const result = getWebhooksQuery(environmentId, params);
|
||||
const result = getWebhooksQuery([environmentId], params);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.where).toMatchObject({
|
||||
environmentId,
|
||||
environmentId: { in: [environmentId] },
|
||||
surveyIds: { hasSome: ["survey1"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("calls pickCommonFilter and buildCommonFilterQuery when baseFilter is present", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValue({ someFilter: "test" } as any);
|
||||
getWebhooksQuery(environmentId, { surveyIds: ["survey1"] } as TGetWebhooksFilter);
|
||||
getWebhooksQuery([environmentId], { surveyIds: ["survey1"] } as TGetWebhooksFilter);
|
||||
expect(pickCommonFilter).toHaveBeenCalled();
|
||||
expect(buildCommonFilterQuery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("buildCommonFilterQuery is not called if no baseFilter is picked", () => {
|
||||
vi.mocked(pickCommonFilter).mockReturnValue(undefined as any);
|
||||
getWebhooksQuery(environmentId, {} as any);
|
||||
getWebhooksQuery([environmentId], {} as any);
|
||||
expect(buildCommonFilterQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,10 +2,10 @@ import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/manag
|
||||
import { TGetWebhooksFilter } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const getWebhooksQuery = (environmentId: string, params?: TGetWebhooksFilter) => {
|
||||
export const getWebhooksQuery = (environmentIds: string[], params?: TGetWebhooksFilter) => {
|
||||
let query: Prisma.WebhookFindManyArgs = {
|
||||
where: {
|
||||
environmentId,
|
||||
environmentId: { in: environmentIds },
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,16 +9,18 @@ import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getWebhooks = async (
|
||||
environmentId: string,
|
||||
environmentIds: string[],
|
||||
params: TGetWebhooksFilter
|
||||
): Promise<Result<ApiResponseWithMeta<Webhook[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const query = getWebhooksQuery(environmentIds, params);
|
||||
|
||||
const [webhooks, count] = await prisma.$transaction([
|
||||
prisma.webhook.findMany({
|
||||
...getWebhooksQuery(environmentId, params),
|
||||
...query,
|
||||
}),
|
||||
prisma.webhook.count({
|
||||
where: getWebhooksQuery(environmentId, params).where,
|
||||
where: query.where,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { checkAuthorization } from "@/modules/api/v2/management/auth/check-authorization";
|
||||
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";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
@@ -23,9 +23,11 @@ export const GET = async (request: NextRequest) =>
|
||||
});
|
||||
}
|
||||
|
||||
const environmentId = authentication.environmentId;
|
||||
const environemntIds = authentication.environmentPermissions.map(
|
||||
(permission) => permission.environmentId
|
||||
);
|
||||
|
||||
const res = await getWebhooks(environmentId, query);
|
||||
const res = await getWebhooks(environemntIds, query);
|
||||
|
||||
if (res.ok) {
|
||||
return responses.successResponse(res.data);
|
||||
@@ -57,24 +59,13 @@ export const POST = async (request: NextRequest) =>
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const environmentId = environmentIdResult.data;
|
||||
|
||||
if (body.environmentId !== environmentId) {
|
||||
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "environmentId", issue: "does not match the surveys environment" }],
|
||||
type: "forbidden",
|
||||
details: [{ field: "environmentId", issue: "does not have permission to create webhook" }],
|
||||
});
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
}
|
||||
|
||||
const createWebhookResult = await createWebhook(body);
|
||||
|
||||
if (!createWebhookResult.ok) {
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
32
apps/web/modules/api/v2/me/route.ts
Normal file
32
apps/web/modules/api/v2/me/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { OrganizationAccessType } from "@formbricks/types/api-key";
|
||||
|
||||
export const GET = async (request: NextRequest) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
handler: async ({ authentication }) => {
|
||||
if (!authentication.organizationAccess?.accessControl?.[OrganizationAccessType.Read]) {
|
||||
return handleApiError(request, {
|
||||
type: "unauthorized",
|
||||
details: [{ field: "organizationId", issue: "unauthorized" }],
|
||||
});
|
||||
}
|
||||
|
||||
return responses.successResponse({
|
||||
data: {
|
||||
environmentPermissions: authentication.environmentPermissions.map((permission) => ({
|
||||
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,27 @@ 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 { userPaths } from "@/modules/api/v2/organizations/[organizationId]/users/lib/openapi";
|
||||
import { rolePaths } from "@/modules/api/v2/roles/lib/openapi";
|
||||
import { bulkContactPaths } from "@/modules/ee/contacts/api/v2/management/contacts/bulk/lib/openapi";
|
||||
import * as yaml from "yaml";
|
||||
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 { ZUser } from "@formbricks/database/zod/users";
|
||||
import { ZWebhook } from "@formbricks/database/zod/webhooks";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
@@ -26,6 +35,8 @@ const document = createDocument({
|
||||
version: "2.0.0",
|
||||
},
|
||||
paths: {
|
||||
...rolePaths,
|
||||
...mePaths,
|
||||
...responsePaths,
|
||||
...bulkContactPaths,
|
||||
...contactPaths,
|
||||
@@ -33,7 +44,9 @@ const document = createDocument({
|
||||
...contactAttributeKeyPaths,
|
||||
...surveyPaths,
|
||||
...webhookPaths,
|
||||
...rolePaths,
|
||||
...teamPaths,
|
||||
...projectTeamPaths,
|
||||
...userPaths,
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
@@ -42,6 +55,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 +88,16 @@ 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.",
|
||||
},
|
||||
{
|
||||
name: "Organizations API > Users",
|
||||
description: "Operations for managing users.",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
@@ -81,13 +110,17 @@ 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,
|
||||
user: ZUser,
|
||||
},
|
||||
},
|
||||
security: [
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
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 from params does not match the authenticated organization ID"
|
||||
);
|
||||
});
|
||||
|
||||
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,21 @@
|
||||
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 (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,129 @@
|
||||
import {
|
||||
ZGetProjectTeamUpdateFilter,
|
||||
ZGetProjectTeamsFilter,
|
||||
ZProjectTeamInput,
|
||||
} 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(),
|
||||
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: {
|
||||
path: z.object({
|
||||
organizationId: ZOrganizationIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The project team to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZProjectTeamInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
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,132 @@
|
||||
import { teamCache } from "@/lib/cache/team";
|
||||
import { getProjectTeamsQuery } from "@/modules/api/v2/organizations/[organizationId]/project-teams/lib/utils";
|
||||
import {
|
||||
TGetProjectTeamsFilter,
|
||||
TProjectTeamInput,
|
||||
ZProjectZTeamUpdateSchema,
|
||||
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
|
||||
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 query = getProjectTeamsQuery(organizationId, params);
|
||||
|
||||
const [projectTeams, count] = await prisma.$transaction([
|
||||
prisma.projectTeam.findMany({
|
||||
...query,
|
||||
}),
|
||||
prisma.projectTeam.count({
|
||||
where: query.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: "projectTeam", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateProjectTeam = async (
|
||||
teamId: string,
|
||||
projectId: string,
|
||||
teamInput: z.infer<typeof ZProjectZTeamUpdateSchema>
|
||||
): 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: "projectTeam", 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: "projectTeam", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
TGetProjectTeamsFilter,
|
||||
TProjectTeamInput,
|
||||
ZProjectZTeamUpdateSchema,
|
||||
} 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 ZProjectZTeamUpdateSchema
|
||||
>);
|
||||
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 ZProjectZTeamUpdateSchema
|
||||
>);
|
||||
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,168 @@
|
||||
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,
|
||||
} 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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function PUT(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);
|
||||
}
|
||||
|
||||
const result = await updateProjectTeam(teamId, projectId, body!);
|
||||
if (!result.ok) {
|
||||
return handleApiError(request, result.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: result.data });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 ZProjectZTeamUpdateSchema = 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(),
|
||||
},
|
||||
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,73 @@
|
||||
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 query = getTeamsQuery(organizationId, params);
|
||||
|
||||
const [teams, count] = await prisma.$transaction([
|
||||
prisma.team.findMany({
|
||||
...query,
|
||||
}),
|
||||
prisma.team.count({
|
||||
where: query.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 }] });
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user