mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-22 06:00:51 -06:00
Compare commits
36 Commits
update-api
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d1167ca80 | ||
|
|
480fd8b729 | ||
|
|
333372d61c | ||
|
|
48a92f3e55 | ||
|
|
ddc767e53e | ||
|
|
432425ea59 | ||
|
|
6075fd3ef8 | ||
|
|
f099a46f83 | ||
|
|
fe54ef66c6 | ||
|
|
4eb0e930f6 | ||
|
|
fae925aa25 | ||
|
|
764a3d2fde | ||
|
|
b5a51f1304 | ||
|
|
140aee749b | ||
|
|
4113dd1873 | ||
|
|
0e0d3780d3 | ||
|
|
38ff01aedc | ||
|
|
cdf687ad80 | ||
|
|
a399fc7f80 | ||
|
|
d294dfb9e8 | ||
|
|
c54a48e70b | ||
|
|
884b6f12ae | ||
|
|
5cae0febc9 | ||
|
|
0e898db710 | ||
|
|
40d54d60d4 | ||
|
|
269e026381 | ||
|
|
d8e286082a | ||
|
|
a8f42ff429 | ||
|
|
133b4072bb | ||
|
|
e8687ca854 | ||
|
|
714ff94c9b | ||
|
|
1a1ed296f8 | ||
|
|
4233321ee2 | ||
|
|
9a75b7f145 | ||
|
|
717c092e2c | ||
|
|
8245f2f6af |
@@ -39,6 +39,7 @@ DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=pu
|
||||
# See optional configurations below if you want to disable these features.
|
||||
|
||||
MAIL_FROM=noreply@example.com
|
||||
MAIL_FROM_NAME=Formbricks
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
||||
@@ -189,6 +190,9 @@ UNSPLASH_ACCESS_KEY=
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# The below is used for Rate Limiting for management API
|
||||
UNKEY_ROOT_KEY=
|
||||
|
||||
# Disable custom cache handler if necessary (e.g. if deployed on Vercel)
|
||||
# CUSTOM_CACHE_DISABLED=1
|
||||
|
||||
@@ -199,4 +203,4 @@ UNSPLASH_ACCESS_KEY=
|
||||
# AI_AZURE_LLM_DEPLOYMENT_ID=
|
||||
|
||||
# NEXT_PUBLIC_INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
12
.github/workflows/e2e.yml
vendored
12
.github/workflows/e2e.yml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web &
|
||||
NODE_ENV=test pnpm start --filter=@formbricks/web | tee app.log 2>&1 &
|
||||
sleep 10 # Optional: gives some buffer for the app to start
|
||||
for attempt in {1..10}; do
|
||||
if [ $(curl -o /dev/null -s -w "%{http_code}" http://localhost:3000/health) -eq 200 ]; then
|
||||
@@ -136,3 +136,13 @@ jobs:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: app-logs
|
||||
path: app.log
|
||||
|
||||
- name: Output App Logs
|
||||
if: failure()
|
||||
run: cat app.log
|
||||
|
||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
merge_group:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@@ -6,6 +6,8 @@
|
||||
"dbaeumer.vscode-eslint", // eslint plugin
|
||||
"esbenp.prettier-vscode", // prettier plugin
|
||||
"Prisma.prisma", // syntax|format|completion for prisma
|
||||
"yzhang.markdown-all-in-one" // nicer markdown support
|
||||
"yzhang.markdown-all-in-one", // nicer markdown support
|
||||
"vitest.explorer", // run tests directly from the code window
|
||||
"sonarsource.sonarlint-vscode" // sonarqube linter for vscode
|
||||
]
|
||||
}
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -3,7 +3,7 @@ Copyright (c) 2024 Formbricks GmbH
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
- All content that resides under the "apps/web/modules/ee" directory of this repository, if these directories exist, is licensed under the license defined in "apps/web/modules/ee/LICENSE".
|
||||
- All content that resides under the "packages/js/", "packages/react-native/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All content that resides under the "packages/js/", "packages/react-native/", "packages/android/", "packages/ios/" and "packages/api/" directories of this repository, if that directories exist, is licensed under the "MIT" license as defined in the "LICENSE" files of these packages.
|
||||
- All third party components incorporated into the Formbricks Software are licensed under the original license provided by the owner of the applicable component.
|
||||
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
@@ -15,7 +16,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams";
|
||||
@@ -19,7 +20,6 @@ import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
|
||||
|
||||
@@ -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 { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const getSurveys = reactCache(
|
||||
async (environmentId: string): Promise<TSurvey[]> =>
|
||||
cache(
|
||||
async () => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
try {
|
||||
const surveysPrisma = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId,
|
||||
status: {
|
||||
not: "completed",
|
||||
},
|
||||
},
|
||||
select: selectSurvey,
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return surveysPrisma.map((surveyPrisma) => transformPrismaSurvey<TSurvey>(surveyPrisma));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
console.error(error);
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getSurveys-${environmentId}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/integrations/notion/components/NotionWrapper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
@@ -21,7 +22,6 @@ import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getNotionDatabases } from "@formbricks/lib/notion/service";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/integrations/slack/components/SlackWrapper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getProjectPermissionByUserId } from "@/modules/ee/teams/lib/roles";
|
||||
@@ -14,7 +15,6 @@ import { getIntegrationByType } from "@formbricks/lib/integration/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProjectByEnvironmentId } from "@formbricks/lib/project/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsOrganizationAIReady,
|
||||
getWhiteLabelPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TMembership } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import Page from "./page";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
FB_LOGO_URL: "https://example.com/mock-logo.png",
|
||||
ENCRYPTION_KEY: "mock-encryption-key",
|
||||
ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key",
|
||||
GITHUB_ID: "mock-github-id",
|
||||
GITHUB_SECRET: "mock-github-secret",
|
||||
GOOGLE_CLIENT_ID: "mock-google-client-id",
|
||||
GOOGLE_CLIENT_SECRET: "mock-google-client-secret",
|
||||
AZUREAD_CLIENT_ID: "mock-azuread-client-id",
|
||||
AZUREAD_CLIENT_SECRET: "mock-azure-client-secret",
|
||||
AZUREAD_TENANT_ID: "mock-azuread-tenant-id",
|
||||
OIDC_CLIENT_ID: "mock-oidc-client-id",
|
||||
OIDC_CLIENT_SECRET: "mock-oidc-client-secret",
|
||||
OIDC_ISSUER: "mock-oidc-issuer",
|
||||
OIDC_DISPLAY_NAME: "mock-oidc-display-name",
|
||||
OIDC_SIGNING_ALGORITHM: "mock-oidc-signing-algorithm",
|
||||
SAML_DATABASE_URL: "mock-saml-database-url",
|
||||
WEBAPP_URL: "mock-webapp-url",
|
||||
SMTP_HOST: "mock-smtp-host",
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/tolgee/server", () => ({
|
||||
getTranslate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/user/service", () => ({
|
||||
getUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/organization/service", () => ({
|
||||
getOrganizationByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/membership/service", () => ({
|
||||
getMembershipByUserIdOrganizationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/membership/utils", () => ({
|
||||
getAccessFlags: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsMultiOrgEnabled: vi.fn(),
|
||||
getIsOrganizationAIReady: vi.fn(),
|
||||
getWhiteLabelPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Page", () => {
|
||||
const mockParams = { environmentId: "test-environment-id" };
|
||||
const mockSession = { user: { id: "test-user-id" } };
|
||||
const mockUser = { id: "test-user-id" } as TUser;
|
||||
const mockOrganization = { id: "test-organization-id", billing: { plan: "free" } } as TOrganization;
|
||||
const mockMembership = { role: "owner" } as TMembership;
|
||||
const mockTranslate = vi.fn((key) => key);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getServerSession).mockResolvedValue(mockSession);
|
||||
vi.mocked(getTranslate).mockResolvedValue(mockTranslate);
|
||||
vi.mocked(getUser).mockResolvedValue(mockUser);
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(mockOrganization);
|
||||
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
|
||||
vi.mocked(getAccessFlags).mockReturnValue({
|
||||
isOwner: true,
|
||||
isManager: false,
|
||||
isBilling: false,
|
||||
isMember: false,
|
||||
});
|
||||
vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true);
|
||||
vi.mocked(getIsOrganizationAIReady).mockResolvedValue(true);
|
||||
vi.mocked(getWhiteLabelPermission).mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("renders the page with organization settings", async () => {
|
||||
const props = {
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
};
|
||||
|
||||
const result = await Page(props);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders if session user id is null", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: null } });
|
||||
|
||||
const props = {
|
||||
params: Promise.resolve({ environmentId: "env-123" }),
|
||||
};
|
||||
|
||||
const result = await Page(props);
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("throws an error if the session is not found", async () => {
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
|
||||
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow("common.session_not_found");
|
||||
});
|
||||
|
||||
it("throws an error if the organization is not found", async () => {
|
||||
vi.mocked(getOrganizationByEnvironmentId).mockResolvedValue(null);
|
||||
|
||||
await expect(Page({ params: Promise.resolve(mockParams) })).rejects.toThrow(
|
||||
"common.organization_not_found"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,8 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsId } from "@/modules/ui/components/settings-id";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import React from "react";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getOrganizationByEnvironmentId } from "@formbricks/lib/organization/service";
|
||||
@@ -84,6 +85,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
environmentId={params.environmentId}
|
||||
isReadOnly={!isOwnerOrManager}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
fbLogoUrl={FB_LOGO_URL}
|
||||
user={user}
|
||||
/>
|
||||
{isMultiOrgEnabled && (
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import React from "react";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
export const SettingsCard = ({
|
||||
|
||||
@@ -200,13 +200,6 @@ export const generateResponseTableColumns = (
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="font-normal">
|
||||
{t("environments.surveys.responses.how_to_identify_users")}
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-slate-900"
|
||||
href="https://formbricks.com/docs/link-surveys/user-identification"
|
||||
target="_blank">
|
||||
{t("common.link_surveys")}
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-slate-900"
|
||||
href="https://formbricks.com/docs/app-surveys/user-identification"
|
||||
|
||||
@@ -17,11 +17,6 @@ interface LinkTabProps {
|
||||
export const LinkTab = ({ survey, webAppUrl, surveyUrl, setSurveyUrl, locale }: LinkTabProps) => {
|
||||
const { t } = useTranslate();
|
||||
const docsLinks = [
|
||||
{
|
||||
title: t("environments.surveys.summary.identify_users"),
|
||||
description: t("environments.surveys.summary.identify_users_description"),
|
||||
link: "https://formbricks.com/docs/link-surveys/user-identification",
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.summary.data_prefilling"),
|
||||
description: t("environments.surveys.summary.data_prefilling_description"),
|
||||
|
||||
@@ -380,7 +380,7 @@ export const getQuestionSummary = async (
|
||||
|
||||
let hasValidAnswer = false;
|
||||
|
||||
if (Array.isArray(answer)) {
|
||||
if (Array.isArray(answer) && question.type === TSurveyQuestionTypeEnum.MultipleChoiceMulti) {
|
||||
answer.forEach((value) => {
|
||||
if (value) {
|
||||
totalSelectionCount++;
|
||||
@@ -396,7 +396,10 @@ export const getQuestionSummary = async (
|
||||
hasValidAnswer = true;
|
||||
}
|
||||
});
|
||||
} else if (typeof answer === "string") {
|
||||
} else if (
|
||||
typeof answer === "string" &&
|
||||
question.type === TSurveyQuestionTypeEnum.MultipleChoiceSingle
|
||||
) {
|
||||
if (answer) {
|
||||
totalSelectionCount++;
|
||||
if (questionChoices.includes(answer)) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { hasOrganizationAccess } from "@/app/lib/api/apiHelper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
import { hasOrganizationAccess } from "@formbricks/lib/auth";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -16,7 +16,7 @@ export const GET = async (_: Request, context: { params: Promise<{ organizationI
|
||||
// check auth
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthenticationError("Not authenticated");
|
||||
const hasAccess = await hasOrganizationAccess(session.user, organizationId);
|
||||
const hasAccess = await hasOrganizationAccess(session.user.id, organizationId);
|
||||
if (!hasAccess) throw new AuthorizationError("Unauthorized");
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdOrganizationId(session?.user.id, organizationId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hasOrganizationAccess } from "@/app/lib/api/apiHelper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { hasOrganizationAccess } from "@formbricks/lib/auth";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getProject } from "@formbricks/lib/project/service";
|
||||
import { AuthenticationError, AuthorizationError } from "@formbricks/types/errors";
|
||||
@@ -15,7 +15,7 @@ export const GET = async (_: Request, context: { params: Promise<{ projectId: st
|
||||
if (!session) throw new AuthenticationError("Not authenticated");
|
||||
const project = await getProject(projectId);
|
||||
if (!project) return notFound();
|
||||
const hasAccess = await hasOrganizationAccess(session.user, project.organizationId);
|
||||
const hasAccess = await hasOrganizationAccess(session.user.id, project.organizationId);
|
||||
if (!hasAccess) throw new AuthorizationError("Unauthorized");
|
||||
// redirect to project's production environment
|
||||
const environments = await getEnvironments(project.id);
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
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 { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getEnvironmentIdFromApiKey } from "./lib/api-key";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ 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, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Promise<string | null> => {
|
||||
const hashedKey = getHash(apiKey);
|
||||
@@ -42,7 +41,7 @@ export const getEnvironmentIdFromApiKey = reactCache(async (apiKey: string): Pro
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[`getEnvironmentIdFromApiKey-${apiKey}`],
|
||||
[`management-api-getEnvironmentIdFromApiKey-${apiKey}`],
|
||||
{
|
||||
tags: [apiKeyCache.tag.byHashedKey(hashedKey)],
|
||||
}
|
||||
|
||||
15
apps/web/app/api/v1/management/me/lib/utils.ts
Normal file
15
apps/web/app/api/v1/management/me/lib/utils.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => {
|
||||
// check for session (browser usage)
|
||||
let session: Session | null;
|
||||
if (req && res) {
|
||||
session = await getServerSession(req, res, authOptions);
|
||||
} else {
|
||||
session = await getServerSession(authOptions);
|
||||
}
|
||||
if (session && "user" in session) return session.user;
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getSessionUser, hashApiKey } from "@/app/lib/api/apiHelper";
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -0,0 +1,26 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
|
||||
export const doesContactExist = reactCache(
|
||||
(id: string): Promise<boolean> =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return !!contact;
|
||||
},
|
||||
[`doesContactExistDisplaysApiV2-${id}`],
|
||||
{
|
||||
tags: [contactCache.tag.byId(id)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
TDisplayCreateInputV2,
|
||||
ZDisplayCreateInputV2,
|
||||
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { validateInputs } from "@formbricks/lib/utils/validate";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { doesContactExist } from "./contact";
|
||||
|
||||
export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promise<{ id: string }> => {
|
||||
validateInputs([displayInput, ZDisplayCreateInputV2]);
|
||||
|
||||
const { environmentId, contactId, surveyId } = displayInput;
|
||||
|
||||
try {
|
||||
const contactExists = contactId ? await doesContactExist(contactId) : false;
|
||||
|
||||
const display = await prisma.display.create({
|
||||
data: {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
|
||||
...(contactExists && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contactId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
select: { id: true, contactId: true, surveyId: true },
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
contactId: display.contactId,
|
||||
surveyId: display.surveyId,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
return display;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
55
apps/web/app/api/v2/client/[environmentId]/displays/route.ts
Normal file
55
apps/web/app/api/v2/client/[environmentId]/displays/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { createDisplay } from "./lib/display";
|
||||
|
||||
interface Context {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const jsonInput = await request.json();
|
||||
const inputValidation = ZDisplayCreateInputV2.safeParse({
|
||||
...jsonInput,
|
||||
environmentId: params.environmentId,
|
||||
});
|
||||
|
||||
if (!inputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (inputValidation.data.contactId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await createDisplay(inputValidation.data);
|
||||
|
||||
await capturePosthogEnvironmentEvent(inputValidation.data.environmentId, "display created");
|
||||
return responses.successResponse(response, true);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZDisplayCreateInput } from "@formbricks/types/displays";
|
||||
|
||||
export const ZDisplayCreateInputV2 = ZDisplayCreateInput.omit({ userId: true }).extend({
|
||||
contactId: ZId.optional(),
|
||||
});
|
||||
|
||||
export type TDisplayCreateInputV2 = z.infer<typeof ZDisplayCreateInputV2>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, OPTIONS } from "@/app/api/v1/client/[environmentId]/environment/route";
|
||||
|
||||
export { OPTIONS, GET };
|
||||
@@ -0,0 +1,6 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, PUT } from "@/app/api/v1/client/[environmentId]/responses/[responseId]/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { contactCache } from "@/lib/cache/contact";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { cache } from "@formbricks/lib/cache";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
|
||||
export const getContact = reactCache((contactId: string) =>
|
||||
cache(
|
||||
async () => {
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
}, {}) as TContactAttributes;
|
||||
|
||||
return {
|
||||
id: contact.id,
|
||||
attributes: contactAttributes,
|
||||
};
|
||||
},
|
||||
[`getContact-responses-api-${contactId}`],
|
||||
{
|
||||
tags: [contactCache.tag.byId(contactId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,145 @@
|
||||
import "server-only";
|
||||
import { responseSelection } from "@/app/api/v1/client/[environmentId]/responses/lib/response";
|
||||
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@formbricks/lib/organization/service";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
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 { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
import { getContact } from "./contact";
|
||||
|
||||
export const createResponse = async (responseInput: TResponseInputV2): Promise<TResponse> => {
|
||||
validateInputs([responseInput, ZResponseInput]);
|
||||
captureTelemetry("response created");
|
||||
|
||||
const {
|
||||
environmentId,
|
||||
language,
|
||||
contactId,
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
ttc: initialTtc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
} = responseInput;
|
||||
|
||||
try {
|
||||
let contact: { id: string; attributes: TContactAttributes } | null = null;
|
||||
let userId: string | undefined = undefined;
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
throw new ResourceNotFoundError("Organization", environmentId);
|
||||
}
|
||||
|
||||
if (contactId) {
|
||||
contact = await getContact(contactId);
|
||||
userId = contact?.attributes.userId;
|
||||
}
|
||||
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData: Prisma.ResponseCreateInput = {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished: finished,
|
||||
data: data,
|
||||
language: language,
|
||||
...(contact?.id && {
|
||||
contact: {
|
||||
connect: {
|
||||
id: contact.id,
|
||||
},
|
||||
},
|
||||
contactAttributes: contact.attributes,
|
||||
}),
|
||||
...(meta && ({ meta } as Prisma.JsonObject)),
|
||||
singleUseId,
|
||||
...(variables && { variables }),
|
||||
ttc: ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
|
||||
const responsePrisma = await prisma.response.create({
|
||||
data: prismaData,
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
const response: TResponse = {
|
||||
...responsePrisma,
|
||||
contact: contact
|
||||
? {
|
||||
id: contact.id,
|
||||
userId: contact.attributes.userId,
|
||||
}
|
||||
: null,
|
||||
tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag),
|
||||
};
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId,
|
||||
id: response.id,
|
||||
contactId: contact?.id,
|
||||
...(singleUseId && { singleUseId }),
|
||||
userId,
|
||||
surveyId,
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
responseId: response.id,
|
||||
});
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
138
apps/web/app/api/v2/client/[environmentId]/responses/route.ts
Normal file
138
apps/web/app/api/v2/client/[environmentId]/responses/route.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { headers } from "next/headers";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { capturePosthogEnvironmentEvent } from "@formbricks/lib/posthogServer";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { createResponse } from "./lib/response";
|
||||
import { TResponseInputV2, ZResponseInputV2 } from "./types/response";
|
||||
|
||||
interface Context {
|
||||
params: Promise<{
|
||||
environmentId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const POST = async (request: Request, context: Context): Promise<Response> => {
|
||||
const params = await context.params;
|
||||
const requestHeaders = await headers();
|
||||
let responseInput;
|
||||
try {
|
||||
responseInput = await request.json();
|
||||
} catch (error) {
|
||||
return responses.badRequestResponse("Invalid JSON in request body", { error: error.message }, true);
|
||||
}
|
||||
|
||||
const { environmentId } = params;
|
||||
const environmentIdValidation = ZId.safeParse(environmentId);
|
||||
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!environmentIdValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(environmentIdValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (!responseInputValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(responseInputValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const agent = new UAParser(userAgent);
|
||||
|
||||
const country =
|
||||
requestHeaders.get("CF-IPCountry") ||
|
||||
requestHeaders.get("X-Vercel-IP-Country") ||
|
||||
requestHeaders.get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
|
||||
const responseInputData = responseInputValidation.data;
|
||||
|
||||
if (responseInputData.contactId) {
|
||||
const isContactsEnabled = await getIsContactsEnabled();
|
||||
if (!isContactsEnabled) {
|
||||
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
|
||||
}
|
||||
}
|
||||
|
||||
// get and check survey
|
||||
const survey = await getSurvey(responseInputData.surveyId);
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
|
||||
}
|
||||
if (survey.environmentId !== environmentId) {
|
||||
return responses.badRequestResponse(
|
||||
"Survey is part of another environment",
|
||||
{
|
||||
"survey.environmentId": survey.environmentId,
|
||||
environmentId,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
let response: TResponse;
|
||||
try {
|
||||
const meta: TResponseInputV2["meta"] = {
|
||||
source: responseInputData?.meta?.source,
|
||||
url: responseInputData?.meta?.url,
|
||||
userAgent: {
|
||||
browser: agent.getBrowser().name,
|
||||
device: agent.getDevice().type || "desktop",
|
||||
os: agent.getOS().name,
|
||||
},
|
||||
country: country,
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
response = await createResponse({
|
||||
...responseInputData,
|
||||
meta,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidInputError) {
|
||||
return responses.badRequestResponse(error.message);
|
||||
} else {
|
||||
console.error(error);
|
||||
return responses.internalServerErrorResponse(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
sendToPipeline({
|
||||
event: "responseCreated",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
|
||||
if (responseInput.finished) {
|
||||
sendToPipeline({
|
||||
event: "responseFinished",
|
||||
environmentId: survey.environmentId,
|
||||
surveyId: response.surveyId,
|
||||
response: response,
|
||||
});
|
||||
}
|
||||
|
||||
await capturePosthogEnvironmentEvent(survey.environmentId, "response created", {
|
||||
surveyId: response.surveyId,
|
||||
surveyType: survey.type,
|
||||
});
|
||||
|
||||
return responses.successResponse({ id: response.id }, true);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZResponseInput } from "@formbricks/types/responses";
|
||||
|
||||
export const ZResponseInputV2 = ZResponseInput.omit({ userId: true }).extend({ contactId: ZId.nullish() });
|
||||
export type TResponseInputV2 = z.infer<typeof ZResponseInputV2>;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/route";
|
||||
|
||||
export { OPTIONS, POST };
|
||||
3
apps/web/app/api/v2/client/[environmentId]/user/route.ts
Normal file
3
apps/web/app/api/v2/client/[environmentId]/user/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
|
||||
|
||||
export { POST, OPTIONS };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { DELETE, GET, PUT } from "@/modules/api/v2/management/responses/[responseId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
3
apps/web/app/api/v2/management/responses/route.ts
Normal file
3
apps/web/app/api/v2/management/responses/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/management/responses/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -1,75 +0,0 @@
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { createHash } from "crypto";
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
export const hasEnvironmentAccess = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
environmentId: string
|
||||
) => {
|
||||
if (req.headers["x-api-key"]) {
|
||||
const ownership = await hasApiEnvironmentAccess(req.headers["x-api-key"].toString(), environmentId);
|
||||
if (!ownership) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const user = await getSessionUser(req, res);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
const ownership = await hasUserEnvironmentAccess(user.id, environmentId);
|
||||
if (!ownership) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export const hasApiEnvironmentAccess = async (apiKey, environmentId) => {
|
||||
// write function to check if the API Key has access to the environment
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey: hashApiKey(apiKey),
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (apiKeyData?.environmentId === environmentId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const hasOrganizationAccess = async (user, organizationId) => {
|
||||
const membership = await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_organizationId: {
|
||||
userId: user.id,
|
||||
organizationId: organizationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (membership) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const getSessionUser = async (req?: NextApiRequest, res?: NextApiResponse) => {
|
||||
// check for session (browser usage)
|
||||
let session: Session | null;
|
||||
if (req && res) {
|
||||
session = await getServerSession(req, res, authOptions);
|
||||
} else {
|
||||
session = await getServerSession(authOptions);
|
||||
}
|
||||
if (session && "user" in session) return session.user;
|
||||
};
|
||||
@@ -15,7 +15,8 @@ interface ApiErrorResponse {
|
||||
| "unauthorized"
|
||||
| "method_not_allowed"
|
||||
| "not_authenticated"
|
||||
| "forbidden";
|
||||
| "forbidden"
|
||||
| "too_many_requests";
|
||||
message: string;
|
||||
details: {
|
||||
[key: string]: string | string[] | number | number[] | boolean | boolean[];
|
||||
@@ -247,7 +248,7 @@ const tooManyRequestsResponse = (
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
code: "internal_server_error",
|
||||
code: "too_many_requests",
|
||||
message,
|
||||
details: {},
|
||||
} as ApiErrorResponse,
|
||||
|
||||
@@ -14,6 +14,11 @@ export const isClientSideApiRoute = (url: string): boolean => {
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isManagementApiRoute = (url: string): boolean => {
|
||||
const regex = /^\/api\/v\d+\/management\//;
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isShareUrlRoute = (url: string): boolean => {
|
||||
const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/;
|
||||
return regex.test(url);
|
||||
|
||||
@@ -12,22 +12,36 @@ import {
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { logApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ipAddress } from "@vercel/functions";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { E2E_TESTING, IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { isValidCallbackUrl } from "@formbricks/lib/utils/url";
|
||||
|
||||
export const middleware = async (request: NextRequest) => {
|
||||
// issue with next auth types; let's review when new fixes are available
|
||||
const token = await getToken({ req: request as any });
|
||||
const enforceHttps = (request: NextRequest): Response | null => {
|
||||
const forwardedProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||
if (IS_PRODUCTION && !E2E_TESTING && forwardedProto !== "https") {
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "forbidden",
|
||||
details: [{ field: "", issue: "Only HTTPS connections are allowed on the management endpoint." }],
|
||||
};
|
||||
logApiError(request, apiError);
|
||||
return NextResponse.json(apiError, { status: 403 });
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
const token = await getToken({ req: request as any });
|
||||
if (isAuthProtectedRoute(request.nextUrl.pathname) && !token) {
|
||||
const loginUrl = `${WEBAPP_URL}/auth/login?callbackUrl=${encodeURIComponent(WEBAPP_URL + request.nextUrl.pathname + request.nextUrl.search)}`;
|
||||
return NextResponse.redirect(loginUrl);
|
||||
@@ -35,13 +49,62 @@ export const middleware = async (request: NextRequest) => {
|
||||
|
||||
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
|
||||
if (callbackUrl && !isValidCallbackUrl(callbackUrl, WEBAPP_URL)) {
|
||||
return NextResponse.json({ error: "Invalid callback URL" });
|
||||
return NextResponse.json({ error: "Invalid callback URL" }, { status: 400 });
|
||||
}
|
||||
if (token && callbackUrl) {
|
||||
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
|
||||
}
|
||||
if (process.env.NODE_ENV !== "production" || RATE_LIMITING_DISABLED) {
|
||||
return NextResponse.next();
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyRateLimiting = (request: NextRequest, ip: string) => {
|
||||
if (isLoginRoute(request.nextUrl.pathname)) {
|
||||
loginLimiter(`login-${ip}`);
|
||||
} else if (isSignupRoute(request.nextUrl.pathname)) {
|
||||
signupLimiter(`signup-${ip}`);
|
||||
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
|
||||
verifyEmailLimiter(`verify-email-${ip}`);
|
||||
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
|
||||
forgotPasswordLimiter(`forgot-password-${ip}`);
|
||||
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
|
||||
clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
|
||||
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
|
||||
if (envIdAndUserId) {
|
||||
const { environmentId, userId } = envIdAndUserId;
|
||||
syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
|
||||
}
|
||||
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
|
||||
shareUrlLimiter(`share-${ip}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const middleware = async (originalRequest: NextRequest) => {
|
||||
// Create a new Request object to override headers and add a unique request ID header
|
||||
const request = new NextRequest(originalRequest, {
|
||||
headers: new Headers(originalRequest.headers),
|
||||
});
|
||||
|
||||
request.headers.set("x-request-id", uuidv4());
|
||||
|
||||
// Create a new NextResponse object to forward the new request with headers
|
||||
const nextResponseWithCustomHeader = NextResponse.next({
|
||||
request: {
|
||||
headers: request.headers,
|
||||
},
|
||||
});
|
||||
|
||||
// Enforce HTTPS for management endpoints
|
||||
if (isManagementApiRoute(request.nextUrl.pathname)) {
|
||||
const httpsResponse = enforceHttps(request);
|
||||
if (httpsResponse) return httpsResponse;
|
||||
}
|
||||
|
||||
// Handle authentication
|
||||
const authResponse = await handleAuth(request);
|
||||
if (authResponse) return authResponse;
|
||||
|
||||
if (!IS_PRODUCTION || RATE_LIMITING_DISABLED) {
|
||||
return nextResponseWithCustomHeader;
|
||||
}
|
||||
|
||||
let ip =
|
||||
@@ -51,32 +114,19 @@ export const middleware = async (request: NextRequest) => {
|
||||
|
||||
if (ip) {
|
||||
try {
|
||||
if (isLoginRoute(request.nextUrl.pathname)) {
|
||||
await loginLimiter(`login-${ip}`);
|
||||
} else if (isSignupRoute(request.nextUrl.pathname)) {
|
||||
await signupLimiter(`signup-${ip}`);
|
||||
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
|
||||
await verifyEmailLimiter(`verify-email-${ip}`);
|
||||
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
|
||||
await forgotPasswordLimiter(`forgot-password-${ip}`);
|
||||
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
|
||||
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
|
||||
|
||||
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
|
||||
if (envIdAndUserId) {
|
||||
const { environmentId, userId } = envIdAndUserId;
|
||||
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
|
||||
}
|
||||
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
|
||||
await shareUrlLimiter(`share-${ip}`);
|
||||
}
|
||||
return NextResponse.next();
|
||||
applyRateLimiting(request, ip);
|
||||
return nextResponseWithCustomHeader;
|
||||
} catch (e) {
|
||||
console.log(`Rate Limiting IP: ${ip}`);
|
||||
return NextResponse.json({ error: "Too many requests, Please try after a while!" }, { status: 429 });
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "too_many_requests",
|
||||
details: [{ field: "", issue: "Too many requests. Please try again later." }],
|
||||
};
|
||||
logApiError(request, apiError);
|
||||
return NextResponse.json(apiError, { status: 429 });
|
||||
}
|
||||
}
|
||||
return NextResponse.next();
|
||||
|
||||
return nextResponseWithCustomHeader;
|
||||
};
|
||||
|
||||
export const config = {
|
||||
@@ -94,5 +144,7 @@ export const config = {
|
||||
"/api/packages/:path*",
|
||||
"/auth/verification-requested",
|
||||
"/auth/forgot-password",
|
||||
"/api/v1/management/:path*",
|
||||
"/api/v2/management/:path*",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => {
|
||||
return (
|
||||
<Input
|
||||
autoFocus={true}
|
||||
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-center text-slate-800 caret-transparent"
|
||||
className="mt-2 w-full min-w-96 text-ellipsis rounded-lg border bg-white px-4 py-2 text-slate-800 caret-transparent"
|
||||
defaultValue={surveyUrl}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -71,7 +71,7 @@ export const ShareSurveyLink = ({
|
||||
return (
|
||||
<div
|
||||
className={`flex max-w-full flex-col items-center justify-center space-x-2 ${survey.singleUse?.enabled ? "flex-col" : "lg:flex-row"}`}>
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} />
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
|
||||
<div className="mt-2 flex items-center justify-center space-x-2">
|
||||
<LanguageDropdown survey={survey} setLanguage={setLanguage} locale={locale} />
|
||||
<Button
|
||||
|
||||
70
apps/web/modules/api/v2/lib/rate-limit.ts
Normal file
70
apps/web/modules/api/v2/lib/rate-limit.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { type LimitOptions, Ratelimit, type RatelimitResponse } from "@unkey/ratelimit";
|
||||
import { MANAGEMENT_API_RATE_LIMIT, RATE_LIMITING_DISABLED, UNKEY_ROOT_KEY } from "@formbricks/lib/constants";
|
||||
import { Result, err, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
export type RateLimitHelper = {
|
||||
identifier: string;
|
||||
opts?: LimitOptions;
|
||||
/**
|
||||
* Using a callback instead of a regular return to provide headers even
|
||||
* when the rate limit is reached and an error is thrown.
|
||||
**/
|
||||
onRateLimiterResponse?: (response: RatelimitResponse) => void;
|
||||
};
|
||||
|
||||
let warningDisplayed = false;
|
||||
|
||||
/** Prevent flooding the logs while testing/building */
|
||||
function logOnce(message: string) {
|
||||
if (warningDisplayed) return;
|
||||
console.warn(message);
|
||||
warningDisplayed = true;
|
||||
}
|
||||
|
||||
export function rateLimiter() {
|
||||
if (RATE_LIMITING_DISABLED) {
|
||||
logOnce("Rate limiting disabled");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
|
||||
if (!UNKEY_ROOT_KEY) {
|
||||
logOnce("Disabled due to not finding UNKEY_ROOT_KEY env variable");
|
||||
return () => ({ success: true, limit: 10, remaining: 999, reset: 0 }) as RatelimitResponse;
|
||||
}
|
||||
const timeout = {
|
||||
fallback: { success: true, limit: 10, remaining: 999, reset: 0 },
|
||||
ms: 5000,
|
||||
};
|
||||
|
||||
const limiter = {
|
||||
api: new Ratelimit({
|
||||
rootKey: UNKEY_ROOT_KEY,
|
||||
namespace: "api",
|
||||
limit: MANAGEMENT_API_RATE_LIMIT.allowedPerInterval,
|
||||
duration: MANAGEMENT_API_RATE_LIMIT.interval * 1000,
|
||||
timeout,
|
||||
}),
|
||||
};
|
||||
|
||||
async function rateLimit({ identifier, opts }: RateLimitHelper) {
|
||||
return await limiter.api.limit(identifier, opts);
|
||||
}
|
||||
|
||||
return rateLimit;
|
||||
}
|
||||
|
||||
export const checkRateLimitAndThrowError = async ({
|
||||
identifier,
|
||||
opts,
|
||||
}: RateLimitHelper): Promise<Result<void, ApiErrorResponseV2>> => {
|
||||
const response = await rateLimiter()({ identifier, opts });
|
||||
const { success } = response;
|
||||
|
||||
if (!success) {
|
||||
return err({
|
||||
type: "too_many_requests",
|
||||
});
|
||||
}
|
||||
return okVoid();
|
||||
};
|
||||
270
apps/web/modules/api/v2/lib/response.ts
Normal file
270
apps/web/modules/api/v2/lib/response.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { ApiErrorDetails, ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiSuccessResponse } from "@/modules/api/v2/types/api-success";
|
||||
|
||||
export type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponseV2;
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
const badRequestResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 400,
|
||||
message: "Bad Request",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 400,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const unauthorizedResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 401,
|
||||
message: "Unauthorized",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const forbiddenResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 403,
|
||||
message: "Forbidden",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const notFoundResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 404,
|
||||
message: "Not Found",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const conflictResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 409,
|
||||
message: "Conflict",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 409,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const unprocessableEntityResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 422,
|
||||
message: "Unprocessable Entity",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const tooManyRequestsResponse = ({
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 429,
|
||||
message: "Too Many Requests",
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const internalServerErrorResponse = ({
|
||||
details = [],
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
details?: ApiErrorDetails;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
} = {}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
error: {
|
||||
code: 500,
|
||||
message: "Internal Server Error",
|
||||
details,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const successResponse = ({
|
||||
data,
|
||||
meta,
|
||||
cors = false,
|
||||
cache = "private, no-store",
|
||||
}: {
|
||||
data: Object;
|
||||
meta?: Record<string, unknown>;
|
||||
cors?: boolean;
|
||||
cache?: string;
|
||||
}) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
data,
|
||||
meta,
|
||||
} as ApiSuccessResponse,
|
||||
{
|
||||
status: 200,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export const responses = {
|
||||
badRequestResponse,
|
||||
unauthorizedResponse,
|
||||
forbiddenResponse,
|
||||
notFoundResponse,
|
||||
conflictResponse,
|
||||
unprocessableEntityResponse,
|
||||
tooManyRequestsResponse,
|
||||
internalServerErrorResponse,
|
||||
successResponse,
|
||||
};
|
||||
107
apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
Normal file
107
apps/web/modules/api/v2/lib/tests/rate-limit.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("when rate limiting is disabled", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@formbricks/lib/constants");
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: true,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning once and return a stubbed response", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
|
||||
const res1 = await rateLimiter()({ identifier: "test-id" });
|
||||
expect(res1).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(warnSpy).toHaveBeenCalledWith("Rate limiting disabled");
|
||||
|
||||
// Subsequent calls won't log again.
|
||||
await rateLimiter()({ identifier: "another-id" });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when UNKEY_ROOT_KEY is missing", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@formbricks/lib/constants");
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "",
|
||||
}));
|
||||
});
|
||||
|
||||
test("should log a warning about missing UNKEY_ROOT_KEY and return stub response", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const { rateLimiter } = await import("@/modules/api/v2/lib/rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
|
||||
const res = await limiterFunc({ identifier: "test-id" });
|
||||
expect(res).toEqual({ success: true, limit: 10, remaining: 999, reset: 0 });
|
||||
expect(warnSpy).toHaveBeenCalledWith("Disabled due to not finding UNKEY_ROOT_KEY env variable");
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("when rate limiting is active (enabled)", () => {
|
||||
const mockResponse = { success: true, limit: 5, remaining: 2, reset: 1000 };
|
||||
let limitMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const constants = await vi.importActual("@formbricks/lib/constants");
|
||||
vi.doMock("@formbricks/lib/constants", () => ({
|
||||
...constants,
|
||||
MANAGEMENT_API_RATE_LIMIT: { allowedPerInterval: 5, interval: 60 },
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
UNKEY_ROOT_KEY: "valid-key",
|
||||
}));
|
||||
|
||||
limitMock = vi.fn().mockResolvedValue(mockResponse);
|
||||
const RatelimitMock = vi.fn().mockImplementation(() => {
|
||||
return { limit: limitMock };
|
||||
});
|
||||
vi.doMock("@unkey/ratelimit", () => ({
|
||||
Ratelimit: RatelimitMock,
|
||||
}));
|
||||
});
|
||||
|
||||
test("should create a rate limiter that calls the limit method with the proper arguments", async () => {
|
||||
const { rateLimiter } = await import("../rate-limit");
|
||||
const limiterFunc = rateLimiter();
|
||||
const res = await limiterFunc({ identifier: "abc", opts: { cost: 1 } });
|
||||
expect(limitMock).toHaveBeenCalledWith("abc", { cost: 1 });
|
||||
expect(res).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns okVoid when rate limit is not exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: true, limit: 5, remaining: 3, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("checkRateLimitAndThrowError returns an error when the rate limit is exceeded", async () => {
|
||||
limitMock.mockResolvedValueOnce({ success: false, limit: 5, remaining: 0, reset: 1000 });
|
||||
|
||||
const { checkRateLimitAndThrowError } = await import("../rate-limit");
|
||||
const result = await checkRateLimitAndThrowError({ identifier: "abc" });
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "too_many_requests" });
|
||||
}
|
||||
});
|
||||
});
|
||||
183
apps/web/modules/api/v2/lib/tests/response.test.ts
Normal file
183
apps/web/modules/api/v2/lib/tests/response.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { responses } from "../response";
|
||||
|
||||
describe("API Responses", () => {
|
||||
describe("badRequestResponse", () => {
|
||||
test("return a 400 response with error details", async () => {
|
||||
const details = [{ field: "param", issue: "invalid" }];
|
||||
const res = responses.badRequestResponse({ details });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 400,
|
||||
message: "Bad Request",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.badRequestResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unauthorizedResponse", () => {
|
||||
test("return a 401 response with the proper error message", async () => {
|
||||
const res = responses.unauthorizedResponse();
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 401,
|
||||
message: "Unauthorized",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.unauthorizedResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("forbiddenResponse", () => {
|
||||
test("return a 403 response", async () => {
|
||||
const res = responses.forbiddenResponse();
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 403,
|
||||
message: "Forbidden",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.forbiddenResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notFoundResponse", () => {
|
||||
test("return a 404 response with error details", async () => {
|
||||
const details = [{ field: "resource", issue: "not found" }];
|
||||
const res = responses.notFoundResponse({ details });
|
||||
expect(res.status).toBe(404);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 404,
|
||||
message: "Not Found",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.notFoundResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("conflictResponse", () => {
|
||||
test("return a 409 response", async () => {
|
||||
const res = responses.conflictResponse();
|
||||
expect(res.status).toBe(409);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 409,
|
||||
message: "Conflict",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.conflictResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("unprocessableEntityResponse", () => {
|
||||
test("return a 422 response with error details", async () => {
|
||||
const details = [{ field: "data", issue: "malformed" }];
|
||||
const res = responses.unprocessableEntityResponse({ details });
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 422,
|
||||
message: "Unprocessable Entity",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.unprocessableEntityResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tooManyRequestsResponse", () => {
|
||||
test("return a 429 response", async () => {
|
||||
const res = responses.tooManyRequestsResponse();
|
||||
expect(res.status).toBe(429);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 429,
|
||||
message: "Too Many Requests",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.tooManyRequestsResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("internalServerErrorResponse", () => {
|
||||
test("return a 500 response with error details", async () => {
|
||||
const details = [{ field: "server", issue: "crashed" }];
|
||||
const res = responses.internalServerErrorResponse({ details });
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({
|
||||
error: {
|
||||
code: 500,
|
||||
message: "Internal Server Error",
|
||||
details,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const res = responses.internalServerErrorResponse({ cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("successResponse", () => {
|
||||
test("return a success response with the provided data", async () => {
|
||||
const data = { foo: "bar" };
|
||||
const meta = { page: 1 };
|
||||
const res = responses.successResponse({ data, meta });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data).toEqual(data);
|
||||
expect(body.meta).toEqual(meta);
|
||||
});
|
||||
|
||||
test("include CORS headers when cors is true", () => {
|
||||
const data = { foo: "bar" };
|
||||
const res = responses.successResponse({ data, cors: true });
|
||||
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
201
apps/web/modules/api/v2/lib/tests/utils.test.ts
Normal file
201
apps/web/modules/api/v2/lib/tests/utils.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { ZodError } from "zod";
|
||||
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
|
||||
|
||||
const mockRequest = new Request("http://localhost");
|
||||
|
||||
// Add the request id header
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
describe("utils", () => {
|
||||
describe("handleApiError", () => {
|
||||
test('return bad request response for "bad_request" error', async () => {
|
||||
const details = [{ field: "param", issue: "invalid" }];
|
||||
const error: ApiErrorResponseV2 = { type: "bad_request", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(400);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(400);
|
||||
expect(body.error.message).toBe("Bad Request");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return unauthorized response for "unauthorized" error', async () => {
|
||||
const error: ApiErrorResponseV2 = { type: "unauthorized" };
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(401);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(401);
|
||||
expect(body.error.message).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
test('return forbidden response for "forbidden" error', async () => {
|
||||
const error: ApiErrorResponseV2 = { type: "forbidden" };
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(403);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(403);
|
||||
expect(body.error.message).toBe("Forbidden");
|
||||
});
|
||||
|
||||
test('return not found response for "not_found" error', async () => {
|
||||
const details = [{ field: "resource", issue: "not found" }];
|
||||
const error: ApiErrorResponseV2 = { type: "not_found", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(404);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(404);
|
||||
expect(body.error.message).toBe("Not Found");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return conflict response for "conflict" error', async () => {
|
||||
const error: ApiErrorResponseV2 = { type: "conflict" };
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(409);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(409);
|
||||
expect(body.error.message).toBe("Conflict");
|
||||
});
|
||||
|
||||
test('return unprocessable entity response for "unprocessable_entity" error', async () => {
|
||||
const details = [{ field: "data", issue: "malformed" }];
|
||||
const error: ApiErrorResponseV2 = { type: "unprocessable_entity", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(422);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(422);
|
||||
expect(body.error.message).toBe("Unprocessable Entity");
|
||||
expect(body.error.details).toEqual(details);
|
||||
});
|
||||
|
||||
test('return too many requests response for "too_many_requests" error', async () => {
|
||||
const error: ApiErrorResponseV2 = { type: "too_many_requests" };
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(429);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(429);
|
||||
expect(body.error.message).toBe("Too Many Requests");
|
||||
});
|
||||
|
||||
test('return internal server error response for "internal_server_error" error with default message', async () => {
|
||||
const details = [{ field: "server", issue: "error occurred" }];
|
||||
const error: ApiErrorResponseV2 = { type: "internal_server_error", details };
|
||||
|
||||
const response = handleApiError(mockRequest, error);
|
||||
expect(response.status).toBe(500);
|
||||
const body = await response.json();
|
||||
expect(body.error.code).toBe(500);
|
||||
expect(body.error.message).toBe("Internal Server Error");
|
||||
expect(body.error.details).toEqual([
|
||||
{ field: "error", issue: "An error occurred while processing your request. Please try again later." },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatZodError", () => {
|
||||
test("correctly format a Zod error", () => {
|
||||
const zodError = {
|
||||
issues: [
|
||||
{
|
||||
path: ["field1"],
|
||||
message: "Invalid value for field1",
|
||||
},
|
||||
{
|
||||
path: ["field2", "subfield"],
|
||||
message: "Field2 subfield is required",
|
||||
},
|
||||
],
|
||||
} as ZodError;
|
||||
|
||||
const formatted = formatZodError(zodError);
|
||||
expect(formatted).toEqual([
|
||||
{ field: "field1", issue: "Invalid value for field1" },
|
||||
{ field: "field2.subfield", issue: "Field2 subfield is required" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("return an empty array if there are no issues", () => {
|
||||
const zodError = { issues: [] } as unknown as ZodError;
|
||||
const formatted = formatZodError(zodError);
|
||||
expect(formatted).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logApiRequest", () => {
|
||||
test("logs API request details", () => {
|
||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc&safeParam=value");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
logApiRequest(mockRequest, 200, 100);
|
||||
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
`[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n correlationId: 123\n queryParams: {"safeParam":"value"}`
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("logs API request details without correlationId and without safe query params", () => {
|
||||
const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test?apikey=123&token=abc");
|
||||
mockRequest.headers.delete("x-request-id");
|
||||
|
||||
logApiRequest(mockRequest, 200, 100);
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(
|
||||
`[API REQUEST DETAILS] GET /api/test - 200 - 100ms\n queryParams: {}`
|
||||
);
|
||||
|
||||
consoleLogSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("logApiError", () => {
|
||||
test("logs API error details", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.set("x-request-id", "123");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "server", issue: "error occurred" }],
|
||||
};
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`[API ERROR DETAILS]\n correlationId: 123\n error: ${JSON.stringify(error, null, 2)}`
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("logs API error details without correlationId", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const mockRequest = new Request("http://localhost/api/test");
|
||||
mockRequest.headers.delete("x-request-id");
|
||||
|
||||
const error: ApiErrorResponseV2 = {
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "server", issue: "error occurred" }],
|
||||
};
|
||||
|
||||
logApiError(mockRequest, error);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
`[API ERROR DETAILS]\n error: ${JSON.stringify(error, null, 2)}`
|
||||
);
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
65
apps/web/modules/api/v2/lib/utils.ts
Normal file
65
apps/web/modules/api/v2/lib/utils.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { responses } from "@/modules/api/v2/lib/response";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
export const handleApiError = (request: Request, err: ApiErrorResponseV2): Response => {
|
||||
logApiError(request, err);
|
||||
|
||||
switch (err.type) {
|
||||
case "bad_request":
|
||||
return responses.badRequestResponse({ details: err.details });
|
||||
case "unauthorized":
|
||||
return responses.unauthorizedResponse();
|
||||
case "forbidden":
|
||||
return responses.forbiddenResponse();
|
||||
case "not_found":
|
||||
return responses.notFoundResponse({ details: err.details });
|
||||
case "conflict":
|
||||
return responses.conflictResponse();
|
||||
case "unprocessable_entity":
|
||||
return responses.unprocessableEntityResponse({ details: err.details });
|
||||
case "too_many_requests":
|
||||
return responses.tooManyRequestsResponse();
|
||||
default:
|
||||
// Replace with a generic error message, because we don't want to expose internal errors to API users.
|
||||
return responses.internalServerErrorResponse({
|
||||
details: [
|
||||
{
|
||||
field: "error",
|
||||
issue: "An error occurred while processing your request. Please try again later.",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const formatZodError = (error: ZodError) => {
|
||||
return error.issues.map((issue) => ({
|
||||
field: issue.path.join("."),
|
||||
issue: issue.message,
|
||||
}));
|
||||
};
|
||||
|
||||
export const logApiRequest = (request: Request, responseStatus: number, duration: number): void => {
|
||||
const method = request.method;
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const correlationId = request.headers.get("x-request-id") || "";
|
||||
const queryParams = Object.fromEntries(url.searchParams.entries());
|
||||
|
||||
const sensitiveParams = ["apikey", "token", "secret"];
|
||||
const safeQueryParams = Object.fromEntries(
|
||||
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[API REQUEST DETAILS] ${method} ${path} - ${responseStatus} - ${duration}ms${correlationId ? `\n correlationId: ${correlationId}` : ""}\n queryParams: ${JSON.stringify(safeQueryParams)}`
|
||||
);
|
||||
};
|
||||
|
||||
export const logApiError = (request: Request, error: ApiErrorResponseV2): void => {
|
||||
const correlationId = request.headers.get("x-request-id") || "";
|
||||
console.error(
|
||||
`[API ERROR DETAILS]${correlationId ? `\n correlationId: ${correlationId}` : ""}\n error: ${JSON.stringify(error, null, 2)}`
|
||||
);
|
||||
};
|
||||
106
apps/web/modules/api/v2/management/auth/api-wrapper.ts
Normal file
106
apps/web/modules/api/v2/management/auth/api-wrapper.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { checkRateLimitAndThrowError } from "@/modules/api/v2/lib/rate-limit";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { err } from "@formbricks/types/error-handlers";
|
||||
import { authenticateRequest } from "./authenticate-request";
|
||||
|
||||
export type HandlerFn<TInput = Record<string, unknown>> = ({
|
||||
authentication,
|
||||
parsedInput,
|
||||
request,
|
||||
}: {
|
||||
authentication: TAuthenticationApiKey;
|
||||
parsedInput: TInput;
|
||||
request: Request;
|
||||
}) => Promise<Response>;
|
||||
|
||||
export type ExtendedSchemas = {
|
||||
body?: z.ZodObject<ZodRawShape>;
|
||||
query?: z.ZodObject<ZodRawShape>;
|
||||
params?: z.ZodObject<ZodRawShape>;
|
||||
};
|
||||
|
||||
// Define a type that returns separate keys for each input type.
|
||||
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
|
||||
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
|
||||
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
|
||||
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : undefined;
|
||||
};
|
||||
|
||||
export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
request,
|
||||
schemas,
|
||||
externalParams,
|
||||
rateLimit = true,
|
||||
handler,
|
||||
}: {
|
||||
request: Request;
|
||||
schemas?: S;
|
||||
externalParams?: Promise<Record<string, any>>;
|
||||
rateLimit?: boolean;
|
||||
handler: HandlerFn<ParsedSchemas<S>>;
|
||||
}): Promise<Response> => {
|
||||
try {
|
||||
const authentication = await authenticateRequest(request);
|
||||
if (!authentication.ok) throw authentication.error;
|
||||
|
||||
let parsedInput: ParsedSchemas<S> = {} as ParsedSchemas<S>;
|
||||
|
||||
if (schemas?.body) {
|
||||
const bodyData = await request.json();
|
||||
const bodyResult = schemas.body.safeParse(bodyData);
|
||||
|
||||
if (!bodyResult.success) {
|
||||
throw err({
|
||||
type: "forbidden",
|
||||
details: formatZodError(bodyResult.error),
|
||||
});
|
||||
}
|
||||
parsedInput.body = bodyResult.data as ParsedSchemas<S>["body"];
|
||||
}
|
||||
|
||||
if (schemas?.query) {
|
||||
const url = new URL(request.url);
|
||||
const queryObject = Object.fromEntries(url.searchParams.entries());
|
||||
const queryResult = schemas.query.safeParse(queryObject);
|
||||
if (!queryResult.success) {
|
||||
throw err({
|
||||
type: "unprocessable_entity",
|
||||
details: formatZodError(queryResult.error),
|
||||
});
|
||||
}
|
||||
parsedInput.query = queryResult.data as ParsedSchemas<S>["query"];
|
||||
}
|
||||
|
||||
if (schemas?.params) {
|
||||
const paramsObject = (await externalParams) || {};
|
||||
console.log("paramsObject: ", paramsObject);
|
||||
const paramsResult = schemas.params.safeParse(paramsObject);
|
||||
if (!paramsResult.success) {
|
||||
throw err({
|
||||
type: "unprocessable_entity",
|
||||
details: formatZodError(paramsResult.error),
|
||||
});
|
||||
}
|
||||
parsedInput.params = paramsResult.data as ParsedSchemas<S>["params"];
|
||||
}
|
||||
|
||||
if (rateLimit) {
|
||||
const rateLimitResponse = await checkRateLimitAndThrowError({
|
||||
identifier: authentication.data.hashedApiKey,
|
||||
});
|
||||
if (!rateLimitResponse.ok) {
|
||||
throw rateLimitResponse.error;
|
||||
}
|
||||
}
|
||||
|
||||
return handler({
|
||||
authentication: authentication.data,
|
||||
parsedInput,
|
||||
request,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleApiError(request, err);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
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",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { logApiRequest } from "@/modules/api/v2/lib/utils";
|
||||
import { ExtendedSchemas, HandlerFn, ParsedSchemas, apiWrapper } from "./api-wrapper";
|
||||
|
||||
export const authenticatedApiClient = async <S extends ExtendedSchemas>({
|
||||
request,
|
||||
schemas,
|
||||
externalParams,
|
||||
rateLimit = true,
|
||||
handler,
|
||||
}: {
|
||||
request: Request;
|
||||
schemas?: S;
|
||||
externalParams?: Promise<Record<string, any>>;
|
||||
rateLimit?: boolean;
|
||||
handler: HandlerFn<ParsedSchemas<S>>;
|
||||
}): Promise<Response> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas,
|
||||
externalParams,
|
||||
rateLimit,
|
||||
handler,
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logApiRequest(request, response.status, duration);
|
||||
|
||||
return response;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
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();
|
||||
};
|
||||
@@ -0,0 +1,300 @@
|
||||
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";
|
||||
import { err, ok, okVoid } from "@formbricks/types/error-handlers";
|
||||
|
||||
vi.mock("../authenticate-request", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/rate-limit", () => ({
|
||||
checkRateLimitAndThrowError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
handleApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("apiWrapper", () => {
|
||||
it("should handle request and return response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(okVoid());
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors and return error response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(err({ type: "unauthorized" }));
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 401 }));
|
||||
|
||||
const handler = vi.fn();
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should parse body schema correctly", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key: "value" }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parsedInput: { body: { key: "value" } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle body schema errors", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ key: 123 }),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const bodySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { body: bodySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should parse query schema correctly", async () => {
|
||||
const request = new Request("http://localhost?key=value");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { query: querySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parsedInput: { query: { key: "value" } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle query schema errors", async () => {
|
||||
const request = new Request("http://localhost?foo%ZZ=abc");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const querySchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { query: querySchema },
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should parse params schema correctly", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { params: paramsSchema },
|
||||
externalParams: Promise.resolve({ key: "value" }),
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parsedInput: { params: { key: "value" } },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle no external params", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { params: paramsSchema },
|
||||
externalParams: undefined,
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle params schema errors", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
|
||||
vi.mocked(handleApiError).mockResolvedValue(new Response("error", { status: 400 }));
|
||||
|
||||
const paramsSchema = z.object({ key: z.string() });
|
||||
const handler = vi.fn();
|
||||
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
schemas: { params: paramsSchema },
|
||||
externalParams: Promise.resolve({ notKey: "value" }),
|
||||
rateLimit: false,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle rate limit errors", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(
|
||||
ok({
|
||||
type: "apiKey",
|
||||
environmentId: "env-id",
|
||||
hashedApiKey: "hashed-api-key",
|
||||
})
|
||||
);
|
||||
vi.mocked(checkRateLimitAndThrowError).mockResolvedValue(
|
||||
err({ type: "rateLimitExceeded" } as unknown as ApiErrorResponseV2)
|
||||
);
|
||||
vi.mocked(handleApiError).mockImplementation(
|
||||
(_request: Request, _error: ApiErrorResponseV2): Response =>
|
||||
new Response("rate limit exceeded", { status: 429 })
|
||||
);
|
||||
|
||||
const handler = vi.fn();
|
||||
const response = await apiWrapper({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { logApiRequest } from "@/modules/api/v2/lib/utils";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { apiWrapper } from "../api-wrapper";
|
||||
import { authenticatedApiClient } from "../authenticated-api-client";
|
||||
|
||||
vi.mock("../api-wrapper", () => ({
|
||||
apiWrapper: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/lib/utils", () => ({
|
||||
logApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticatedApiClient", () => {
|
||||
it("should log request and return response", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(apiWrapper).mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
vi.mocked(logApiRequest).mockReturnValue();
|
||||
|
||||
const handler = vi.fn().mockResolvedValue(new Response("ok", { status: 200 }));
|
||||
const response = await authenticatedApiClient({
|
||||
request,
|
||||
handler,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(logApiRequest).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
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" });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
|
||||
|
||||
export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactAttributeKey",
|
||||
summary: "Get a contact attribute key",
|
||||
description: "Gets a contact attribute key from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeKeyId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attribute key retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteContactAttributeKey",
|
||||
summary: "Delete a contact attribute key",
|
||||
description: "Deletes a contact attribute key from the database.",
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attribute key deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateContactAttributeKey",
|
||||
summary: "Update a contact attribute key",
|
||||
description: "Updates a contact attribute key in the database.",
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeKeyId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The contact attribute key to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKeyInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attribute key updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
deleteContactAttributeKeyEndpoint,
|
||||
getContactAttributeKeyEndpoint,
|
||||
updateContactAttributeKeyEndpoint,
|
||||
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/openapi";
|
||||
import {
|
||||
ZContactAttributeKeyInput,
|
||||
ZGetContactAttributeKeysFilter,
|
||||
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
|
||||
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactAttributeKeys",
|
||||
summary: "Get contact attribute keys",
|
||||
description: "Gets contact attribute keys from the database.",
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestParams: {
|
||||
query: ZGetContactAttributeKeysFilter,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attribute keys retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(ZContactAttributeKey),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createContactAttributeKey",
|
||||
summary: "Create a contact attribute key",
|
||||
description: "Creates a contact attribute key in the database.",
|
||||
tags: ["Management API > Contact Attribute Keys"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The contact attribute key to create",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeKeyInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Contact attribute key created successfully.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
|
||||
"/contact-attribute-keys": {
|
||||
get: getContactAttributeKeysEndpoint,
|
||||
post: createContactAttributeKeyEndpoint,
|
||||
},
|
||||
"/contact-attribute-keys/{id}": {
|
||||
get: getContactAttributeKeyEndpoint,
|
||||
put: updateContactAttributeKeyEndpoint,
|
||||
delete: deleteContactAttributeKeyEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
|
||||
|
||||
export const ZGetContactAttributeKeysFilter = z
|
||||
.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
}
|
||||
);
|
||||
|
||||
export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
|
||||
key: true,
|
||||
name: true,
|
||||
description: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
}).openapi({
|
||||
ref: "contactAttributeKeyInput",
|
||||
description: "Input data for creating or updating a contact attribute",
|
||||
});
|
||||
|
||||
export type TContactAttributeKeyInput = z.infer<typeof ZContactAttributeKeyInput>;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ZContactAttributeInput } from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
|
||||
|
||||
export const getContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactAttribute",
|
||||
summary: "Get a contact attribute",
|
||||
description: "Gets a contact attribute from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttribute,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteContactAttribute",
|
||||
summary: "Delete a contact attribute",
|
||||
description: "Deletes a contact attribute from the database.",
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttribute,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateContactAttribute",
|
||||
summary: "Update a contact attribute",
|
||||
description: "Updates a contact attribute in the database.",
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactAttributeId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The response to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Response updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttribute,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
deleteContactAttributeEndpoint,
|
||||
getContactAttributeEndpoint,
|
||||
updateContactAttributeEndpoint,
|
||||
} from "@/modules/api/v2/management/contact-attributes/[contactAttributeId]/lib/openapi";
|
||||
import {
|
||||
ZContactAttributeInput,
|
||||
ZGetContactAttributesFilter,
|
||||
} from "@/modules/api/v2/management/contact-attributes/types/contact-attributes";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZContactAttribute } from "@formbricks/types/contact-attribute";
|
||||
|
||||
export const getContactAttributesEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContactAttributes",
|
||||
summary: "Get contact attributes",
|
||||
description: "Gets contact attributes from the database.",
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
requestParams: {
|
||||
query: ZGetContactAttributesFilter,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact attributes retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(ZContactAttribute),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createContactAttributeEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createContactAttribute",
|
||||
summary: "Create a contact attribute",
|
||||
description: "Creates a contact attribute in the database.",
|
||||
tags: ["Management API > Contact Attributes"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The contact attribute to create",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactAttributeInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Contact attribute created successfully.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const contactAttributePaths: ZodOpenApiPathsObject = {
|
||||
"/contact-attributes": {
|
||||
get: getContactAttributesEndpoint,
|
||||
post: createContactAttributeEndpoint,
|
||||
},
|
||||
"/contact-attributes/{id}": {
|
||||
get: getContactAttributeEndpoint,
|
||||
put: updateContactAttributeEndpoint,
|
||||
delete: deleteContactAttributeEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
import { ZContactAttribute } from "@formbricks/database/zod/contact-attributes";
|
||||
|
||||
export const ZGetContactAttributesFilter = z
|
||||
.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
}
|
||||
);
|
||||
|
||||
export const ZContactAttributeInput = ZContactAttribute.pick({
|
||||
attributeKeyId: true,
|
||||
contactId: true,
|
||||
value: true,
|
||||
}).openapi({
|
||||
ref: "contactAttributeInput",
|
||||
description: "Input data for creating or updating a contact attribute",
|
||||
});
|
||||
|
||||
export type TContactAttributeInput = z.infer<typeof ZContactAttributeInput>;
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ZContactInput } from "@/modules/api/v2/management/contacts/types/contacts";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZContact } from "@formbricks/database/zod/contact";
|
||||
|
||||
export const getContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContact",
|
||||
summary: "Get a contact",
|
||||
description: "Gets a contact from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Contacts"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContact,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteContact",
|
||||
summary: "Delete a contact",
|
||||
description: "Deletes a contact from the database.",
|
||||
tags: ["Management API > Contacts"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contact deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContact,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateContact",
|
||||
summary: "Update a contact",
|
||||
description: "Updates a contact in the database.",
|
||||
tags: ["Management API > Contacts"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
contactId: z.string().cuid2(),
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The response to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Response updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContact,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
67
apps/web/modules/api/v2/management/contacts/lib/openapi.ts
Normal file
67
apps/web/modules/api/v2/management/contacts/lib/openapi.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
deleteContactEndpoint,
|
||||
getContactEndpoint,
|
||||
updateContactEndpoint,
|
||||
} from "@/modules/api/v2/management/contacts/[contactId]/lib/openapi";
|
||||
import { ZContactInput, ZGetContactsFilter } from "@/modules/api/v2/management/contacts/types/contacts";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZContact } from "@formbricks/database/zod/contact";
|
||||
|
||||
export const getContactsEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getContacts",
|
||||
summary: "Get contacts",
|
||||
description: "Gets contacts from the database.",
|
||||
requestParams: {
|
||||
query: ZGetContactsFilter,
|
||||
},
|
||||
tags: ["Management API > Contacts"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Contacts retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(ZContact),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createContactEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createContact",
|
||||
summary: "Create a contact",
|
||||
description: "Creates a contact in the database.",
|
||||
tags: ["Management API > Contacts"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The contact to create",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContactInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Contact created successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZContact,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const contactPaths: ZodOpenApiPathsObject = {
|
||||
"/contacts": {
|
||||
get: getContactsEndpoint,
|
||||
post: createContactEndpoint,
|
||||
},
|
||||
"/contacts/{id}": {
|
||||
get: getContactEndpoint,
|
||||
put: updateContactEndpoint,
|
||||
delete: deleteContactEndpoint,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from "zod";
|
||||
import { ZContact } from "@formbricks/database/zod/contact";
|
||||
|
||||
export const ZGetContactsFilter = z
|
||||
.object({
|
||||
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
|
||||
skip: z.coerce.number().nonnegative().optional().default(0),
|
||||
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
|
||||
order: z.enum(["asc", "desc"]).optional().default("desc"),
|
||||
startDate: z.coerce.date().optional(),
|
||||
endDate: z.coerce.date().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.startDate && data.endDate && data.startDate > data.endDate) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "startDate must be before endDate",
|
||||
}
|
||||
);
|
||||
|
||||
export const ZContactInput = ZContact.pick({
|
||||
userId: true,
|
||||
environmentId: true,
|
||||
})
|
||||
.partial({
|
||||
userId: true,
|
||||
})
|
||||
.openapi({
|
||||
ref: "contactCreate",
|
||||
description: "A contact to create",
|
||||
});
|
||||
|
||||
export type TContactInput = z.infer<typeof ZContactInput>;
|
||||
44
apps/web/modules/api/v2/management/lib/api-key.ts
Normal file
44
apps/web/modules/api/v2/management/lib/api-key.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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)],
|
||||
}
|
||||
)();
|
||||
});
|
||||
16
apps/web/modules/api/v2/management/lib/helper.ts
Normal file
16
apps/web/modules/api/v2/management/lib/helper.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { fetchEnvironmentId } from "@/modules/api/v2/management/lib/services";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Result, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getEnvironmentId = async (
|
||||
id: string,
|
||||
isResponseId: boolean
|
||||
): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
const result = await fetchEnvironmentId(id, isResponseId);
|
||||
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return ok(result.data.environmentId);
|
||||
};
|
||||
22
apps/web/modules/api/v2/management/lib/openapi.ts
Normal file
22
apps/web/modules/api/v2/management/lib/openapi.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
deleteResponseEndpoint,
|
||||
getResponseEndpoint,
|
||||
updateResponseEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
||||
import {
|
||||
createResponseEndpoint,
|
||||
getResponsesEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/lib/openapi";
|
||||
import { ZodOpenApiPathsObject } from "zod-openapi";
|
||||
|
||||
export const responsePaths: ZodOpenApiPathsObject = {
|
||||
"/responses": {
|
||||
get: getResponsesEndpoint,
|
||||
post: createResponseEndpoint,
|
||||
},
|
||||
"/responses/{id}": {
|
||||
get: getResponseEndpoint,
|
||||
put: updateResponseEndpoint,
|
||||
delete: deleteResponseEndpoint,
|
||||
},
|
||||
};
|
||||
43
apps/web/modules/api/v2/management/lib/services.ts
Normal file
43
apps/web/modules/api/v2/management/lib/services.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
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 { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const fetchEnvironmentId = reactCache(async (id: string, isResponseId: boolean) =>
|
||||
cache(
|
||||
async (): Promise<Result<{ environmentId: string }, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const result = await prisma.survey.findFirst({
|
||||
where: isResponseId ? { responses: { some: { id } } } : { id },
|
||||
select: {
|
||||
environmentId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: isResponseId ? "response" : "survey", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
|
||||
return ok({ environmentId: result.environmentId });
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: isResponseId ? "response" : "survey", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`services-getEnvironmentId-${id}-${isResponseId}`],
|
||||
{
|
||||
tags: [responseCache.tag.byId(id), responseNoteCache.tag.byResponseId(id), surveyCache.tag.byId(id)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export const apiKey = "test-api-key";
|
||||
export const environmentId = "h8bfgyetrmvdh5v4cvexogd9";
|
||||
81
apps/web/modules/api/v2/management/lib/tests/api-key.test.ts
Normal file
81
apps/web/modules/api/v2/management/lib/tests/api-key.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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" }]);
|
||||
}
|
||||
});
|
||||
});
|
||||
43
apps/web/modules/api/v2/management/lib/tests/helper.test.ts
Normal file
43
apps/web/modules/api/v2/management/lib/tests/helper.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { getEnvironmentId } from "../helper";
|
||||
import { fetchEnvironmentId } from "../services";
|
||||
|
||||
vi.mock("../services", () => ({
|
||||
fetchEnvironmentId: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Helper Functions", () => {
|
||||
it("should return environmentId for surveyId", async () => {
|
||||
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
|
||||
|
||||
const result = await getEnvironmentId("survey-id", false);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe("env-id");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return environmentId for responseId", async () => {
|
||||
vi.mocked(fetchEnvironmentId).mockResolvedValue(ok({ environmentId: "env-id" }));
|
||||
|
||||
const result = await getEnvironmentId("response-id", true);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe("env-id");
|
||||
}
|
||||
});
|
||||
|
||||
it("should return error if getSurveyAndEnvironmentId fails", async () => {
|
||||
vi.mocked(fetchEnvironmentId).mockResolvedValue(
|
||||
err({ type: "not_found" } as unknown as ApiErrorResponseV2)
|
||||
);
|
||||
|
||||
const result = await getEnvironmentId("invalid-id", true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { fetchEnvironmentId } from "../services";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Services", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getSurveyAndEnvironmentId", () => {
|
||||
test("should return surveyId and environmentId for responseId", async () => {
|
||||
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
|
||||
environmentId: "env-id",
|
||||
responses: [{ surveyId: "survey-id" }],
|
||||
});
|
||||
|
||||
const result = await fetchEnvironmentId("response-id", true);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({ environmentId: "env-id" });
|
||||
}
|
||||
});
|
||||
|
||||
test("should return surveyId and environmentId for surveyId", async () => {
|
||||
vi.mocked(prisma.survey.findFirst).mockResolvedValue({
|
||||
id: "survey-id",
|
||||
environmentId: "env-id",
|
||||
});
|
||||
|
||||
const result = await fetchEnvironmentId("survey-id", false);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({ environmentId: "env-id" });
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error if response is not found", async () => {
|
||||
vi.mocked(prisma.survey.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await fetchEnvironmentId("invalid-id", true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error if survey is not found", async () => {
|
||||
vi.mocked(prisma.survey.findFirst).mockResolvedValue(null);
|
||||
|
||||
const result = await fetchEnvironmentId("invalid-id", false);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("not_found");
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error if prisma query fails for responseId", async () => {
|
||||
vi.mocked(prisma.survey.findFirst).mockRejectedValue(new Error("Internal server error"));
|
||||
|
||||
const result = await fetchEnvironmentId("response-id", true);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
|
||||
test("should return internal_server_error if prisma query fails for surveyId", async () => {
|
||||
vi.mocked(prisma.survey.findFirst).mockRejectedValue(new Error("Internal server error"));
|
||||
|
||||
const result = await fetchEnvironmentId("survey-id", false);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toBe("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
30
apps/web/modules/api/v2/management/lib/tests/utils.test.ts
Normal file
30
apps/web/modules/api/v2/management/lib/tests/utils.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hashApiKey } from "../utils";
|
||||
|
||||
describe("hashApiKey", () => {
|
||||
test("generate the correct sha256 hash for a given input", () => {
|
||||
const input = "test";
|
||||
const expectedHash = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08";
|
||||
const result = hashApiKey(input);
|
||||
expect(result).toEqual(expectedHash);
|
||||
});
|
||||
|
||||
test("return a string with length 64", () => {
|
||||
const input = "another-api-key";
|
||||
const result = hashApiKey(input);
|
||||
expect(result).toHaveLength(64);
|
||||
});
|
||||
|
||||
test("produce the same hash for identical inputs", () => {
|
||||
const input = "consistentKey";
|
||||
const firstHash = hashApiKey(input);
|
||||
const secondHash = hashApiKey(input);
|
||||
expect(firstHash).toEqual(secondHash);
|
||||
});
|
||||
|
||||
test("generate different hashes for different inputs", () => {
|
||||
const hash1 = hashApiKey("key1");
|
||||
const hash2 = hashApiKey("key2");
|
||||
expect(hash1).not.toEqual(hash2);
|
||||
});
|
||||
});
|
||||
3
apps/web/modules/api/v2/management/lib/utils.ts
Normal file
3
apps/web/modules/api/v2/management/lib/utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { displayCache } from "@formbricks/lib/display/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const deleteDisplay = async (displayId: string): Promise<Result<boolean, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const display = await prisma.display.delete({
|
||||
where: {
|
||||
id: displayId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
contactId: true,
|
||||
surveyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
displayCache.revalidate({
|
||||
id: display.id,
|
||||
contactId: display.contactId,
|
||||
surveyId: display.surveyId,
|
||||
});
|
||||
|
||||
return ok(true);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "display", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "display", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { responseIdSchema } from "@/modules/api/v2/management/responses/[responseId]/types/responses";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject } from "zod-openapi";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
import { ZResponseInput } from "@formbricks/types/responses";
|
||||
|
||||
export const getResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getResponse",
|
||||
summary: "Get a response",
|
||||
description: "Gets a response from the database.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
}),
|
||||
},
|
||||
tags: ["Management API > Responses"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Response retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const deleteResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "deleteResponse",
|
||||
summary: "Delete a response",
|
||||
description: "Deletes a response from the database.",
|
||||
tags: ["Management API > Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Response deleted successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updateResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "updateResponse",
|
||||
summary: "Update a response",
|
||||
description: "Updates a response in the database.",
|
||||
tags: ["Management API > Responses"],
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
id: responseIdSchema,
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The response to update",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponseInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Response updated successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
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 { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Response } 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 { cache } from "@formbricks/lib/cache";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getResponse = reactCache(async (responseId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Response, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const responsePrisma = await prisma.response.findUnique({
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!responsePrisma) {
|
||||
return err({ type: "not_found", details: [{ field: "response", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(responsePrisma);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "response", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`management-getResponse-${responseId}`],
|
||||
{
|
||||
tags: [responseCache.tag.byId(responseId), responseNoteCache.tag.byResponseId(responseId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const deleteResponse = async (responseId: string): Promise<Result<Response, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const deletedResponse = await prisma.response.delete({
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
});
|
||||
|
||||
if (deletedResponse.displayId) {
|
||||
const deleteDisplayResult = await deleteDisplay(deletedResponse.displayId);
|
||||
if (!deleteDisplayResult.ok) {
|
||||
return deleteDisplayResult;
|
||||
}
|
||||
}
|
||||
const surveyQuestionsResult = await getSurveyQuestions(deletedResponse.surveyId);
|
||||
|
||||
if (!surveyQuestionsResult.ok) {
|
||||
return surveyQuestionsResult;
|
||||
}
|
||||
|
||||
await findAndDeleteUploadedFilesInResponse(deletedResponse.data, surveyQuestionsResult.data.questions);
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId: surveyQuestionsResult.data.environmentId,
|
||||
id: deletedResponse.id,
|
||||
surveyId: deletedResponse.surveyId,
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
responseId: deletedResponse.id,
|
||||
});
|
||||
|
||||
return ok(deletedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "response", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const updateResponse = async (
|
||||
responseId: string,
|
||||
responseInput: z.infer<typeof responseUpdateSchema>
|
||||
): Promise<Result<Response, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const updatedResponse = await prisma.response.update({
|
||||
where: {
|
||||
id: responseId,
|
||||
},
|
||||
data: responseInput,
|
||||
});
|
||||
|
||||
responseCache.revalidate({
|
||||
id: updatedResponse.id,
|
||||
surveyId: updatedResponse.surveyId,
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
responseId: updatedResponse.id,
|
||||
});
|
||||
|
||||
return ok(updatedResponse);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2016" || error.code === "P2025") {
|
||||
return err({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
}
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "response", issue: error.message }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Survey } 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 { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getSurveyQuestions = reactCache(async (surveyId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Pick<Survey, "questions" | "environmentId">, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
select: {
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!survey) {
|
||||
return err({ type: "not_found", details: [{ field: "survey", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(survey);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "survey", issue: error.message }] });
|
||||
}
|
||||
},
|
||||
[`management-getSurveyQuestions-${surveyId}`],
|
||||
{
|
||||
tags: [surveyCache.tag.byId(surveyId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Display } from "@prisma/client";
|
||||
|
||||
export const mockDisplay: Display = {
|
||||
id: "jcvb2vzt7ok3ftjsds4gt1gm",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
contactId: "con_1",
|
||||
surveyId: "rp2di001zicbm3mk8je1ue9u",
|
||||
responseId: "ka4lox8ehrcafhd1753g8szv",
|
||||
status: "responded",
|
||||
};
|
||||
|
||||
export const displayId = "jcvb2vzt7ok3ftjsds4gt1gm";
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Response, Survey } from "@prisma/client";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const responseId = "goy9hd7uautij04aosslsplb";
|
||||
|
||||
export const responseInput: Omit<Response, "id"> = {
|
||||
data: { file: "fileUrl" },
|
||||
surveyId: "kbr8tnr2q2vgztyrfnqlgfjt",
|
||||
displayId: "jowdit1qrf04t97jcc0io9di",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
finished: true,
|
||||
contactAttributes: {},
|
||||
contactId: "olwablfltg9eszoh0nz83w02",
|
||||
endingId: "i4k59a2m6fk70vwpn2d9b7a7",
|
||||
variables: [],
|
||||
ttc: {},
|
||||
language: "en",
|
||||
meta: {},
|
||||
singleUseId: "4c02dc5f-eff1-4020-9a9b-a16efd929653",
|
||||
};
|
||||
|
||||
export const response: Response = {
|
||||
id: responseId,
|
||||
...responseInput,
|
||||
};
|
||||
|
||||
export const survey: Pick<Survey, "questions" | "environmentId"> = {
|
||||
questions: [
|
||||
{
|
||||
id: "ggaw04zw7gx7uxodk5da7if8",
|
||||
type: TSurveyQuestionTypeEnum.FileUpload,
|
||||
headline: { en: "Question 1" },
|
||||
required: true,
|
||||
allowMultipleFiles: true,
|
||||
},
|
||||
],
|
||||
environmentId: "z5t8e52wy6xvi61ubebs2e4i",
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Survey } from "@prisma/client";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const survey: Pick<Survey, "id" | "questions"> = {
|
||||
id: "rp2di001zicbm3mk8je1ue9u",
|
||||
questions: [
|
||||
{
|
||||
id: "i0e9y9ya4pl9iyrurlrak3yq",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Question Text", de: "Fragetext" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Response, Survey } from "@prisma/client";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const environmentId = "u8qa6u0tlxb6160pi2jb8s4p";
|
||||
|
||||
export const openTextQuestion: Survey["questions"][number] = {
|
||||
id: "y3ydd3td2iq09wa599cxo1md",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
charLimit: {
|
||||
enabled: true,
|
||||
},
|
||||
inputType: "text",
|
||||
required: true,
|
||||
headline: { en: "Open Text Question" },
|
||||
insightsEnabled: true,
|
||||
};
|
||||
|
||||
export const fileUploadQuestion: Survey["questions"][number] = {
|
||||
id: "y3ydd3td2iq09wa599cxo1me",
|
||||
type: TSurveyQuestionTypeEnum.FileUpload,
|
||||
headline: { en: "File Upload Question" },
|
||||
required: true,
|
||||
allowMultipleFiles: true,
|
||||
buttonLabel: { en: "Upload" },
|
||||
};
|
||||
|
||||
export const responseData: Response["data"] = {
|
||||
[openTextQuestion.id]: "Open Text Answer",
|
||||
[fileUploadQuestion.id]: [
|
||||
`https://example.com/dummy/${environmentId}/private/file1.png`,
|
||||
`https://example.com/dummy/${environmentId}/private/file2.pdf`,
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { displayId, mockDisplay } from "./__mocks__/display.mock";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { deleteDisplay } from "../display";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
display: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Display Lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("delete the display successfully ", async () => {
|
||||
vi.mocked(prisma.display.delete).mockResolvedValue(mockDisplay);
|
||||
|
||||
const result = await deleteDisplay(mockDisplay.id);
|
||||
expect(prisma.display.delete).toHaveBeenCalledWith({
|
||||
where: { id: mockDisplay.id },
|
||||
select: {
|
||||
id: true,
|
||||
contactId: true,
|
||||
surveyId: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error when the display is not found", async () => {
|
||||
vi.mocked(prisma.display.delete).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Display not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Display not found",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const result = await deleteDisplay(mockDisplay.id);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "display", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error when prisma.display.delete throws", async () => {
|
||||
vi.mocked(prisma.display.delete).mockRejectedValue(new Error("Delete error"));
|
||||
|
||||
const result = await deleteDisplay(displayId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "display", issue: "Delete error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,225 @@
|
||||
import { response, responseId, responseInput, survey } from "./__mocks__/response.mock";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ok, okVoid } from "@formbricks/types/error-handlers";
|
||||
import { deleteDisplay } from "../display";
|
||||
import { deleteResponse, getResponse, updateResponse } from "../response";
|
||||
import { getSurveyQuestions } from "../survey";
|
||||
import { findAndDeleteUploadedFilesInResponse } from "../utils";
|
||||
|
||||
vi.mock("../display", () => ({
|
||||
deleteDisplay: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../survey", () => ({
|
||||
getSurveyQuestions: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils", () => ({
|
||||
findAndDeleteUploadedFilesInResponse: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
findUnique: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
display: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
survey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Response Lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getResponse", () => {
|
||||
test("return the response when found", async () => {
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(response);
|
||||
|
||||
const result = await getResponse(responseId);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
expect(prisma.response.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: responseId },
|
||||
});
|
||||
});
|
||||
|
||||
test("return a not_found error when the response is missing", async () => {
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getResponse(responseId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error when prisma throws an error", async () => {
|
||||
vi.mocked(prisma.response.findUnique).mockRejectedValue(new Error("DB error"));
|
||||
|
||||
const result = await getResponse(responseId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "response", issue: "DB error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteResponse", () => {
|
||||
test("delete the response, delete the display and remove uploaded files", async () => {
|
||||
vi.mocked(prisma.response.delete).mockResolvedValue(response);
|
||||
vi.mocked(deleteDisplay).mockResolvedValue(ok(true));
|
||||
vi.mocked(getSurveyQuestions).mockResolvedValue(ok(survey));
|
||||
vi.mocked(findAndDeleteUploadedFilesInResponse).mockResolvedValue(okVoid());
|
||||
|
||||
const result = await deleteResponse(responseId);
|
||||
expect(prisma.response.delete).toHaveBeenCalledWith({
|
||||
where: { id: responseId },
|
||||
});
|
||||
expect(deleteDisplay).toHaveBeenCalledWith(response.displayId);
|
||||
expect(getSurveyQuestions).toHaveBeenCalledWith(response.surveyId);
|
||||
expect(findAndDeleteUploadedFilesInResponse).toHaveBeenCalledWith(response.data, survey.questions);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("return an error if deleteDisplay fails", async () => {
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(response);
|
||||
vi.mocked(prisma.response.delete).mockResolvedValue(response);
|
||||
vi.mocked(deleteDisplay).mockResolvedValue({
|
||||
ok: false,
|
||||
error: { type: "internal_server_error", details: [{ field: "display", issue: "delete failed" }] },
|
||||
});
|
||||
|
||||
const result = await deleteResponse(responseId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "display", issue: "delete failed" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an error if getSurveyQuestions fails", async () => {
|
||||
vi.mocked(prisma.response.findUnique).mockResolvedValue(response);
|
||||
vi.mocked(prisma.response.delete).mockResolvedValue(response);
|
||||
vi.mocked(deleteDisplay).mockResolvedValue(ok(true));
|
||||
vi.mocked(getSurveyQuestions).mockResolvedValue({
|
||||
ok: false,
|
||||
error: { type: "not_found", details: [{ field: "survey", issue: "not found" }] },
|
||||
});
|
||||
|
||||
const result = await deleteResponse(responseId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("catch exceptions and return an internal_server_error", async () => {
|
||||
vi.mocked(prisma.response.delete).mockRejectedValue(new Error("Unexpected error"));
|
||||
const result = await deleteResponse(responseId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "response", issue: "Unexpected error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("handle prisma client error code P2025", async () => {
|
||||
vi.mocked(prisma.response.delete).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Response not found",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const result = await deleteResponse(responseId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateResponse", () => {
|
||||
test("update the response and revalidate caches", async () => {
|
||||
vi.mocked(prisma.response.update).mockResolvedValue(response);
|
||||
|
||||
const result = await updateResponse(responseId, responseInput);
|
||||
expect(prisma.response.update).toHaveBeenCalledWith({
|
||||
where: { id: responseId },
|
||||
data: responseInput,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error when the response is not found", async () => {
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(
|
||||
new PrismaClientKnownRequestError("Response not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "1.0.0",
|
||||
meta: {
|
||||
cause: "Response not found",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const result = await updateResponse(responseId, responseInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "response", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an error when prisma.response.update throws", async () => {
|
||||
vi.mocked(prisma.response.update).mockRejectedValue(new Error("Update failed"));
|
||||
const result = await updateResponse(responseId, responseInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "response", issue: "Update failed" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { survey } from "./__mocks__/survey.mock";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getSurveyQuestions } from "../survey";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Survey Lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getSurveyQuestions", () => {
|
||||
test("return survey questions and environmentId when the survey is found", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(survey);
|
||||
|
||||
const result = await getSurveyQuestions(survey.id);
|
||||
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: survey.id },
|
||||
select: {
|
||||
environmentId: true,
|
||||
questions: true,
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(survey);
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error when the survey does not exist", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getSurveyQuestions(survey.id);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "survey", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error when prisma.survey.findUnique throws an error", async () => {
|
||||
vi.mocked(prisma.survey.findUnique).mockRejectedValue(new Error("DB error"));
|
||||
|
||||
const result = await getSurveyQuestions(survey.id);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "survey", issue: "DB error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { environmentId, fileUploadQuestion, openTextQuestion, responseData } from "./__mocks__/utils.mock";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { deleteFile } from "@formbricks/lib/storage/service";
|
||||
import { okVoid } from "@formbricks/types/error-handlers";
|
||||
import { findAndDeleteUploadedFilesInResponse } from "../utils";
|
||||
|
||||
vi.mock("@formbricks/lib/storage/service", () => ({
|
||||
deleteFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("findAndDeleteUploadedFilesInResponse", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("delete files for file upload questions and return okVoid", async () => {
|
||||
vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" });
|
||||
|
||||
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
|
||||
|
||||
expect(deleteFile).toHaveBeenCalledTimes(2);
|
||||
expect(deleteFile).toHaveBeenCalledWith(environmentId, "private", "file1.png");
|
||||
expect(deleteFile).toHaveBeenCalledWith(environmentId, "private", "file2.pdf");
|
||||
expect(result).toEqual(okVoid());
|
||||
});
|
||||
|
||||
test("not call deleteFile if no file upload questions match response data", async () => {
|
||||
const result = await findAndDeleteUploadedFilesInResponse(responseData, [openTextQuestion]);
|
||||
|
||||
expect(deleteFile).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(okVoid());
|
||||
});
|
||||
|
||||
test("handle invalid file URLs and log errors", async () => {
|
||||
const invalidFileUrl = "https://example.com/invalid-url";
|
||||
const responseData = {
|
||||
[fileUploadQuestion.id]: [invalidFileUrl],
|
||||
};
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
|
||||
|
||||
expect(deleteFile).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).toHaveBeenCalled();
|
||||
expect(result).toEqual(okVoid());
|
||||
|
||||
consoleErrorSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("process multiple file URLs", async () => {
|
||||
vi.mocked(deleteFile).mockResolvedValue({ success: true, message: "File deleted successfully" });
|
||||
|
||||
const result = await findAndDeleteUploadedFilesInResponse(responseData, [fileUploadQuestion]);
|
||||
|
||||
expect(deleteFile).toHaveBeenCalledTimes(2);
|
||||
expect(deleteFile).toHaveBeenNthCalledWith(1, environmentId, "private", "file1.png");
|
||||
expect(deleteFile).toHaveBeenNthCalledWith(2, environmentId, "private", "file2.pdf");
|
||||
expect(result).toEqual(okVoid());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Response, Survey } from "@prisma/client";
|
||||
import { deleteFile } from "@formbricks/lib/storage/service";
|
||||
import { Result, okVoid } from "@formbricks/types/error-handlers";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export const findAndDeleteUploadedFilesInResponse = async (
|
||||
responseData: Response["data"],
|
||||
questions: Survey["questions"]
|
||||
): Promise<Result<void, ApiErrorResponseV2>> => {
|
||||
const fileUploadQuestions = new Set(
|
||||
questions.filter((question) => question.type === TSurveyQuestionTypeEnum.FileUpload).map((q) => q.id)
|
||||
);
|
||||
|
||||
const fileUrls = Object.entries(responseData)
|
||||
.filter(([questionId]) => fileUploadQuestions.has(questionId))
|
||||
.flatMap(([, questionResponse]) => questionResponse as string[]);
|
||||
|
||||
const deletionPromises = fileUrls.map(async (fileUrl) => {
|
||||
try {
|
||||
const { pathname } = new URL(fileUrl);
|
||||
const [, environmentId, accessType, fileName] = pathname.split("/").filter(Boolean);
|
||||
|
||||
if (!environmentId || !accessType || !fileName) {
|
||||
throw new Error(`Invalid file path: ${pathname}`);
|
||||
}
|
||||
return deleteFile(environmentId, accessType as "private" | "public", fileName);
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete file ${fileUrl}:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(deletionPromises);
|
||||
|
||||
return okVoid();
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
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 { z } from "zod";
|
||||
import { responseIdSchema, responseUpdateSchema } from "./types/responses";
|
||||
|
||||
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params } = parsedInput;
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(params.responseId, true);
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
}
|
||||
|
||||
const response = await getResponse(params.responseId);
|
||||
if (!response.ok) {
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
},
|
||||
});
|
||||
|
||||
export const DELETE = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
},
|
||||
externalParams: props.params,
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { params } = parsedInput;
|
||||
|
||||
if (!params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(params.responseId, true);
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
}
|
||||
|
||||
const response = await deleteResponse(params.responseId);
|
||||
|
||||
if (!response.ok) {
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
},
|
||||
});
|
||||
|
||||
export const PUT = (request: Request, props: { params: Promise<{ responseId: string }> }) =>
|
||||
authenticatedApiClient({
|
||||
request,
|
||||
externalParams: props.params,
|
||||
schemas: {
|
||||
params: z.object({ responseId: responseIdSchema }),
|
||||
body: responseUpdateSchema,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
const { body, params } = parsedInput;
|
||||
|
||||
if (!body || !params) {
|
||||
return handleApiError(request, {
|
||||
type: "bad_request",
|
||||
details: [{ field: !body ? "body" : "params", issue: "missing" }],
|
||||
});
|
||||
}
|
||||
|
||||
const environmentIdResult = await getEnvironmentId(params.responseId, true);
|
||||
if (!environmentIdResult.ok) {
|
||||
return handleApiError(request, environmentIdResult.error);
|
||||
}
|
||||
|
||||
const checkAuthorizationResult = await checkAuthorization({
|
||||
authentication,
|
||||
environmentId: environmentIdResult.data,
|
||||
});
|
||||
|
||||
if (!checkAuthorizationResult.ok) {
|
||||
return handleApiError(request, checkAuthorizationResult.error);
|
||||
}
|
||||
|
||||
const response = await updateResponse(params.responseId, body);
|
||||
|
||||
if (!response.ok) {
|
||||
return handleApiError(request, response.error);
|
||||
}
|
||||
|
||||
return responses.successResponse({ data: response.data });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from "zod";
|
||||
import { extendZodWithOpenApi } from "zod-openapi";
|
||||
import { ZResponse } from "@formbricks/database/zod/responses";
|
||||
|
||||
extendZodWithOpenApi(z);
|
||||
|
||||
export const responseIdSchema = z
|
||||
.string()
|
||||
.cuid2()
|
||||
.openapi({
|
||||
ref: "responseId",
|
||||
description: "The ID of the response",
|
||||
param: {
|
||||
name: "id",
|
||||
in: "path",
|
||||
},
|
||||
});
|
||||
|
||||
export const responseUpdateSchema = ZResponse.omit({
|
||||
id: true,
|
||||
surveyId: true,
|
||||
}).openapi({
|
||||
ref: "responseUpdate",
|
||||
description: "A response to update.",
|
||||
});
|
||||
67
apps/web/modules/api/v2/management/responses/lib/openapi.ts
Normal file
67
apps/web/modules/api/v2/management/responses/lib/openapi.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
deleteResponseEndpoint,
|
||||
getResponseEndpoint,
|
||||
updateResponseEndpoint,
|
||||
} from "@/modules/api/v2/management/responses/[responseId]/lib/openapi";
|
||||
import { ZGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { z } from "zod";
|
||||
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
|
||||
import { ZResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
|
||||
export const getResponsesEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "getResponses",
|
||||
summary: "Get responses",
|
||||
description: "Gets responses from the database.",
|
||||
requestParams: {
|
||||
query: ZGetResponsesFilter.sourceType().required(),
|
||||
},
|
||||
tags: ["Management API > Responses"],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Responses retrieved successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.array(ZResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createResponseEndpoint: ZodOpenApiOperationObject = {
|
||||
operationId: "createResponse",
|
||||
summary: "Create a response",
|
||||
description: "Creates a response in the database.",
|
||||
tags: ["Management API > Responses"],
|
||||
requestBody: {
|
||||
required: true,
|
||||
description: "The response to create",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponseInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "Response created successfully.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: ZResponse,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const responsePaths: ZodOpenApiPathsObject = {
|
||||
"/responses": {
|
||||
get: getResponsesEndpoint,
|
||||
post: createResponseEndpoint,
|
||||
},
|
||||
"/responses/{id}": {
|
||||
get: getResponseEndpoint,
|
||||
put: updateResponseEndpoint,
|
||||
delete: deleteResponseEndpoint,
|
||||
},
|
||||
};
|
||||
184
apps/web/modules/api/v2/management/responses/lib/organization.ts
Normal file
184
apps/web/modules/api/v2/management/responses/lib/organization.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { Organization } 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 { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const getOrganizationIdFromEnvironmentId = reactCache(async (environmentId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<string, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
projects: {
|
||||
some: {
|
||||
environments: {
|
||||
some: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok(organization.id);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`management-getOrganizationIdFromEnvironmentId-${environmentId}`],
|
||||
{
|
||||
tags: [organizationCache.tag.byEnvironmentId(environmentId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getOrganizationBilling = reactCache(async (organizationId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<Pick<Organization, "billing">, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
select: {
|
||||
billing: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
|
||||
}
|
||||
return ok(organization);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`management-getOrganizationBilling-${organizationId}`],
|
||||
{
|
||||
tags: [organizationCache.tag.byId(organizationId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getAllEnvironmentsFromOrganizationId = reactCache(async (organizationId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<string[], ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const organization = await prisma.organization.findUnique({
|
||||
where: {
|
||||
id: organizationId,
|
||||
},
|
||||
|
||||
select: {
|
||||
projects: {
|
||||
select: {
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
return err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] });
|
||||
}
|
||||
|
||||
const environmentIds = organization.projects
|
||||
.flatMap((project) => project.environments)
|
||||
.map((environment) => environment.id);
|
||||
|
||||
return ok(environmentIds);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`management-getAllEnvironmentsFromOrganizationId-${organizationId}`],
|
||||
{
|
||||
tags: [organizationCache.tag.byId(organizationId)],
|
||||
}
|
||||
)()
|
||||
);
|
||||
|
||||
export const getMonthlyOrganizationResponseCount = reactCache(async (organizationId: string) =>
|
||||
cache(
|
||||
async (): Promise<Result<number, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const organization = await getOrganizationBilling(organizationId);
|
||||
if (!organization.ok) {
|
||||
return err(organization.error);
|
||||
}
|
||||
|
||||
// Determine the start date based on the plan type
|
||||
let startDate: Date;
|
||||
if (organization.data.billing.plan === "free") {
|
||||
// For free plans, use the first day of the current calendar month
|
||||
const now = new Date();
|
||||
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
} else {
|
||||
// For other plans, use the periodStart from billing
|
||||
if (!organization.data.billing.periodStart) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "billing period start is not set" }],
|
||||
});
|
||||
}
|
||||
startDate = organization.data.billing.periodStart;
|
||||
}
|
||||
|
||||
// Get all environment IDs for the organization
|
||||
const environmentIdsResult = await getAllEnvironmentsFromOrganizationId(organizationId);
|
||||
if (!environmentIdsResult.ok) {
|
||||
return err(environmentIdsResult.error);
|
||||
}
|
||||
|
||||
// Use Prisma's aggregate to count responses for all environments
|
||||
const responseAggregations = await prisma.response.aggregate({
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{ survey: { environmentId: { in: environmentIdsResult.data } } },
|
||||
{ createdAt: { gte: startDate } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// The result is an aggregation of the total count
|
||||
return ok(responseAggregations._count.id);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: error.message }],
|
||||
});
|
||||
}
|
||||
},
|
||||
[`management-getMonthlyOrganizationResponseCount-${organizationId}`],
|
||||
{
|
||||
revalidate: 60 * 60 * 2, // 2 hours
|
||||
}
|
||||
)()
|
||||
);
|
||||
153
apps/web/modules/api/v2/management/responses/lib/response.ts
Normal file
153
apps/web/modules/api/v2/management/responses/lib/response.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import "server-only";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationBilling,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
} from "@/modules/api/v2/management/responses/lib/organization";
|
||||
import { getResponsesQuery } from "@/modules/api/v2/management/responses/lib/utils";
|
||||
import { TGetResponsesFilter, TResponseInput } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
|
||||
import { Prisma, Response } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { responseCache } from "@formbricks/lib/response/cache";
|
||||
import { calculateTtcTotal } from "@formbricks/lib/response/utils";
|
||||
import { responseNoteCache } from "@formbricks/lib/responseNote/cache";
|
||||
import { captureTelemetry } from "@formbricks/lib/telemetry";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const createResponse = async (
|
||||
environmentId: string,
|
||||
responseInput: TResponseInput
|
||||
): Promise<Result<Response, ApiErrorResponseV2>> => {
|
||||
captureTelemetry("response created");
|
||||
|
||||
const {
|
||||
surveyId,
|
||||
displayId,
|
||||
finished,
|
||||
data,
|
||||
language,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
ttc: initialTtc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
endingId,
|
||||
} = responseInput;
|
||||
|
||||
try {
|
||||
const ttc = initialTtc ? (finished ? calculateTtcTotal(initialTtc) : initialTtc) : {};
|
||||
|
||||
const prismaData: Prisma.ResponseCreateInput = {
|
||||
survey: {
|
||||
connect: {
|
||||
id: surveyId,
|
||||
},
|
||||
},
|
||||
display: displayId ? { connect: { id: displayId } } : undefined,
|
||||
finished,
|
||||
data,
|
||||
language,
|
||||
meta,
|
||||
singleUseId,
|
||||
variables,
|
||||
ttc,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
endingId,
|
||||
};
|
||||
|
||||
const organizationIdResult = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
if (!organizationIdResult.ok) {
|
||||
return err(organizationIdResult.error);
|
||||
}
|
||||
|
||||
const organizationResult = await getOrganizationBilling(organizationIdResult.data);
|
||||
if (!organizationResult.ok) {
|
||||
return err(organizationResult.error);
|
||||
}
|
||||
const organization = organizationResult.data;
|
||||
|
||||
const response = await prisma.response.create({
|
||||
data: prismaData,
|
||||
});
|
||||
|
||||
responseCache.revalidate({
|
||||
environmentId,
|
||||
id: response.id,
|
||||
...(singleUseId && { singleUseId }),
|
||||
surveyId,
|
||||
});
|
||||
|
||||
responseNoteCache.revalidate({
|
||||
responseId: response.id,
|
||||
});
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
const responsesCountResult = await getMonthlyOrganizationResponseCount(organizationIdResult.data);
|
||||
if (!responsesCountResult.ok) {
|
||||
return err(responsesCountResult.error);
|
||||
}
|
||||
|
||||
const responsesCount = responsesCountResult.data;
|
||||
const responsesLimit = organization.billing.limits.monthly.responses;
|
||||
|
||||
if (responsesLimit && responsesCount >= responsesLimit) {
|
||||
try {
|
||||
await sendPlanLimitsReachedEventToPosthogWeekly(environmentId, {
|
||||
plan: organization.billing.plan,
|
||||
limits: {
|
||||
projects: null,
|
||||
monthly: {
|
||||
responses: responsesLimit,
|
||||
miu: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Log error but do not throw it
|
||||
console.error(`Error sending plan limits reached event to Posthog: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ok(response);
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "response", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
|
||||
export const getResponses = async (
|
||||
environmentId: string,
|
||||
params: TGetResponsesFilter
|
||||
): Promise<Result<ApiResponseWithMeta<Response[]>, ApiErrorResponseV2>> => {
|
||||
try {
|
||||
const [responses, count] = await prisma.$transaction([
|
||||
prisma.response.findMany({
|
||||
...getResponsesQuery(environmentId, params),
|
||||
}),
|
||||
prisma.response.count({
|
||||
where: getResponsesQuery(environmentId, params).where,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!responses) {
|
||||
return err({ type: "not_found", details: [{ field: "responses", issue: "not found" }] });
|
||||
}
|
||||
|
||||
return ok({
|
||||
data: responses,
|
||||
meta: {
|
||||
total: count,
|
||||
limit: params.limit,
|
||||
offset: params.skip,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return err({ type: "internal_server_error", details: [{ field: "responses", issue: error.message }] });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Organization } from "@prisma/client";
|
||||
|
||||
export const organizationId = "zo6u7apbattt8dquvzbgjjwb";
|
||||
export const environmentId = "oh5cq6yu418itha55vsuj47e";
|
||||
|
||||
export const organizationBilling: Organization["billing"] = {
|
||||
stripeCustomerId: "cus_P78901234567890123456789",
|
||||
plan: "scale",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
monthly: { responses: 100, miu: 1000 },
|
||||
projects: 1,
|
||||
},
|
||||
periodStart: new Date(),
|
||||
};
|
||||
|
||||
export const organizationEnvironments = {
|
||||
projects: [
|
||||
{
|
||||
environments: [{ id: "w6pljnz4l9ljgmyl51xv8ah8" }, { id: "v5sfypq4ib6vjelccho23lmn" }],
|
||||
},
|
||||
{ environments: [{ id: "ffbv7bmhs52yd8beebu6be2l" }] },
|
||||
],
|
||||
};
|
||||
|
||||
export const environmentIds = [
|
||||
"w6pljnz4l9ljgmyl51xv8ah8",
|
||||
"v5sfypq4ib6vjelccho23lmn",
|
||||
"ffbv7bmhs52yd8beebu6be2l",
|
||||
];
|
||||
@@ -0,0 +1,96 @@
|
||||
import { TGetResponsesFilter } from "@/modules/api/v2/management/responses/types/responses";
|
||||
import { Organization, Response } from "@prisma/client";
|
||||
|
||||
export const responseInput: Omit<Response, "id"> = {
|
||||
surveyId: "lygo31gfsexlr4lh6rq8dxyl",
|
||||
displayId: "cgt5e6dw1vsf1bv2ki5gj845",
|
||||
finished: true,
|
||||
data: { key: "value" },
|
||||
language: "en",
|
||||
meta: {},
|
||||
singleUseId: "c9471238-d6c5-42b4-bd13-00e4d0360586",
|
||||
variables: {},
|
||||
ttc: { sample: 1 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
endingId: "lowzqpqnmjbmjowvth1u87wp",
|
||||
contactAttributes: {},
|
||||
contactId: null,
|
||||
};
|
||||
|
||||
export const responseInputNotFinished: Omit<Response, "id"> = {
|
||||
surveyId: "lygo31gfsexlr4lh6rq8dxyl",
|
||||
displayId: "cgt5e6dw1vsf1bv2ki5gj845",
|
||||
finished: false,
|
||||
data: { key: "value" },
|
||||
language: "en",
|
||||
meta: {},
|
||||
singleUseId: "c9471238-d6c5-42b4-bd13-00e4d0360586",
|
||||
variables: {},
|
||||
ttc: { sample: 1 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
endingId: "lowzqpqnmjbmjowvth1u87wp",
|
||||
contactAttributes: {},
|
||||
contactId: null,
|
||||
};
|
||||
|
||||
export const responseInputWithoutTtc: Omit<Response, "id"> = {
|
||||
surveyId: "lygo31gfsexlr4lh6rq8dxyl",
|
||||
displayId: "cgt5e6dw1vsf1bv2ki5gj845",
|
||||
finished: false,
|
||||
data: { key: "value" },
|
||||
language: "en",
|
||||
meta: {},
|
||||
singleUseId: "c9471238-d6c5-42b4-bd13-00e4d0360586",
|
||||
variables: {},
|
||||
ttc: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
endingId: "lowzqpqnmjbmjowvth1u87wp",
|
||||
contactAttributes: {},
|
||||
contactId: null,
|
||||
};
|
||||
|
||||
export const responseInputWithoutDisplay: Omit<Response, "id"> = {
|
||||
surveyId: "lygo31gfsexlr4lh6rq8dxyl",
|
||||
displayId: null,
|
||||
finished: false,
|
||||
data: { key: "value" },
|
||||
language: "en",
|
||||
meta: {},
|
||||
singleUseId: "c9471238-d6c5-42b4-bd13-00e4d0360586",
|
||||
variables: {},
|
||||
ttc: { sample: 1 },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
endingId: "lowzqpqnmjbmjowvth1u87wp",
|
||||
contactAttributes: {},
|
||||
contactId: null,
|
||||
};
|
||||
|
||||
export const response: Response = {
|
||||
id: "bauptoqxslg42k7axss0q146",
|
||||
...responseInput,
|
||||
};
|
||||
|
||||
export const environmentId = "ou9sjm7a7qnilxhhhfszct95";
|
||||
export const organizationId = "qybv4vk77pw71vnq9rmfrsvi";
|
||||
|
||||
export const organizationBilling: Organization["billing"] = {
|
||||
stripeCustomerId: "cus_P78901234567890123456789",
|
||||
plan: "free",
|
||||
period: "monthly",
|
||||
limits: {
|
||||
monthly: { responses: 100, miu: 1000 },
|
||||
projects: 1,
|
||||
},
|
||||
periodStart: new Date(),
|
||||
};
|
||||
|
||||
export const responseFilter: TGetResponsesFilter = {
|
||||
limit: 10,
|
||||
skip: 0,
|
||||
sortBy: "createdAt",
|
||||
order: "asc",
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
environmentId,
|
||||
environmentIds,
|
||||
organizationBilling,
|
||||
organizationEnvironments,
|
||||
organizationId,
|
||||
} from "./__mocks__/organization.mock";
|
||||
import {
|
||||
getAllEnvironmentsFromOrganizationId,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationBilling,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
} from "@/modules/api/v2/management/responses/lib/organization";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
organization: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
aggregate: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Organization Lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getOrganizationIdFromEnvironmentId", () => {
|
||||
test("return organization id when found", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ id: organizationId });
|
||||
|
||||
const result = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
projects: { some: { environments: { some: { id: environmentId } } } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe(organizationId);
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error when organization is not found", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
|
||||
const result = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "organization", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error when an exception is thrown", async () => {
|
||||
const error = new Error("DB error");
|
||||
vi.mocked(prisma.organization.findFirst).mockRejectedValue(error);
|
||||
const result = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "DB error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrganizationBilling", () => {
|
||||
test("return organization billing when found", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
|
||||
|
||||
const result = await getOrganizationBilling(organizationId);
|
||||
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: organizationId },
|
||||
select: { billing: true },
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.billing).toEqual(organizationBilling);
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error when organization is not found", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
|
||||
const result = await getOrganizationBilling(organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "organization", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("handle PrismaClientKnownRequestError", async () => {
|
||||
const error = new Error("DB error");
|
||||
vi.mocked(prisma.organization.findFirst).mockRejectedValue(error);
|
||||
|
||||
const result = await getOrganizationBilling(organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "DB error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAllEnvironmentsFromOrganizationId", () => {
|
||||
test("return all environments from organization", async () => {
|
||||
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
|
||||
const result = await getAllEnvironmentsFromOrganizationId(organizationId);
|
||||
expect(prisma.organization.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: organizationId },
|
||||
select: {
|
||||
projects: {
|
||||
select: {
|
||||
environments: { select: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(environmentIds);
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error when organization is not found", async () => {
|
||||
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
|
||||
const result = await getAllEnvironmentsFromOrganizationId(organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "organization", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error when an exception is thrown", async () => {
|
||||
const error = new Error("DB error");
|
||||
vi.mocked(prisma.organization.findUnique).mockRejectedValue(error);
|
||||
const result = await getAllEnvironmentsFromOrganizationId(organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "DB error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getMonthlyOrganizationResponseCount", () => {
|
||||
test("return error if getOrganizationBilling returns error", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue(null);
|
||||
const result = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "organization", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return error if billing plan is not free and periodStart is not set", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
billing: { ...organizationBilling, periodStart: null },
|
||||
});
|
||||
|
||||
const result = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "billing period start is not set" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return response count", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
|
||||
vi.mocked(prisma.response.aggregate).mockResolvedValue({ _count: { id: 5 } });
|
||||
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
|
||||
|
||||
const result = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
expect(prisma.response.aggregate).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
test("return for a free plan", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
billing: { ...organizationBilling, plan: "free" },
|
||||
});
|
||||
vi.mocked(prisma.response.aggregate).mockResolvedValue({ _count: { id: 5 } });
|
||||
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
|
||||
|
||||
const result = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
test("handle internal_server_error in aggregation", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
|
||||
const error = new Error("Aggregate error");
|
||||
vi.mocked(prisma.response.aggregate).mockRejectedValue(error);
|
||||
vi.mocked(prisma.organization.findUnique).mockResolvedValue(organizationEnvironments);
|
||||
|
||||
const result = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "Aggregate error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("handle error when getAllEnvironmentsFromOrganizationId fails", async () => {
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: organizationBilling });
|
||||
vi.mocked(prisma.organization.findUnique).mockResolvedValue(null);
|
||||
|
||||
const result = await getMonthlyOrganizationResponseCount(organizationId);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "organization", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
environmentId,
|
||||
organizationBilling,
|
||||
organizationId,
|
||||
response,
|
||||
responseFilter,
|
||||
responseInput,
|
||||
responseInputNotFinished,
|
||||
responseInputWithoutDisplay,
|
||||
responseInputWithoutTtc,
|
||||
} from "./__mocks__/response.mock";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationBilling,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
} from "@/modules/api/v2/management/responses/lib/organization";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { sendPlanLimitsReachedEventToPosthogWeekly } from "@formbricks/lib/posthogServer";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { createResponse, getResponses } from "../response";
|
||||
|
||||
vi.mock("@formbricks/lib/posthogServer", () => ({
|
||||
sendPlanLimitsReachedEventToPosthogWeekly: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/responses/lib/organization", () => ({
|
||||
getOrganizationIdFromEnvironmentId: vi.fn(),
|
||||
getOrganizationBilling: vi.fn(),
|
||||
getMonthlyOrganizationResponseCount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
response: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: true,
|
||||
IS_PRODUCTION: false,
|
||||
}));
|
||||
|
||||
describe("Response Lib", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createResponse", () => {
|
||||
test("create a response successfully", async () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
expect(prisma.response.create).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("handle response for initialTtc not finished", async () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputNotFinished);
|
||||
expect(prisma.response.create).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("handle response for initialTtc not provided", async () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputWithoutTtc);
|
||||
expect(prisma.response.create).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("handle response for display not provided", async () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(50));
|
||||
|
||||
const result = await createResponse(environmentId, responseInputWithoutDisplay);
|
||||
expect(prisma.response.create).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("return error if getOrganizationIdFromEnvironmentId fails", async () => {
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(
|
||||
err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] })
|
||||
);
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "organization", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return error if getOrganizationBilling fails", async () => {
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(
|
||||
err({ type: "not_found", details: [{ field: "organization", issue: "not found" }] })
|
||||
);
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "organization", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("send plan limit event when in cloud and responses limit is reached", async () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockImplementation(() => Promise.resolve(""));
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
|
||||
expect(sendPlanLimitsReachedEventToPosthogWeekly).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("handle error getting monthly organization response count", async () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(
|
||||
err({ type: "internal_server_error", details: [{ field: "organization", issue: "Aggregate error" }] })
|
||||
);
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "organization", issue: "Aggregate error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("handle error sending plan limits reached event", async () => {
|
||||
vi.mocked(prisma.response.create).mockResolvedValue(response);
|
||||
|
||||
vi.mocked(getOrganizationIdFromEnvironmentId).mockResolvedValue(ok(organizationId));
|
||||
|
||||
vi.mocked(getOrganizationBilling).mockResolvedValue(ok({ billing: organizationBilling }));
|
||||
|
||||
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(ok(100));
|
||||
|
||||
vi.mocked(sendPlanLimitsReachedEventToPosthogWeekly).mockRejectedValue(
|
||||
new Error("Error sending plan limits")
|
||||
);
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual(response);
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error error if prisma create fails", async () => {
|
||||
vi.mocked(prisma.response.create).mockRejectedValue(new Error("Internal server error"));
|
||||
|
||||
const result = await createResponse(environmentId, responseInput);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.type).toEqual("internal_server_error");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResponses", () => {
|
||||
test("return responses with meta information", async () => {
|
||||
const responses = [response];
|
||||
prisma.$transaction = vi.fn().mockResolvedValue([responses, responses.length]);
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
data: [response],
|
||||
meta: {
|
||||
total: responses.length,
|
||||
limit: responseFilter.limit,
|
||||
offset: responseFilter.skip,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return a not_found error if responses are not found", async () => {
|
||||
prisma.$transaction = vi.fn().mockResolvedValue([null, 0]);
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "not_found",
|
||||
details: [{ field: "responses", issue: "not found" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("return an internal_server_error error if prisma transaction fails", async () => {
|
||||
prisma.$transaction = vi.fn().mockRejectedValue(new Error("Internal server error"));
|
||||
|
||||
const result = await getResponses(environmentId, responseFilter);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({
|
||||
type: "internal_server_error",
|
||||
details: [{ field: "responses", issue: "Internal server error" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user