diff --git a/.env.example b/.env.example index b917c54d25..44ae588dee 100644 --- a/.env.example +++ b/.env.example @@ -190,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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3517881c48..53d31bb810 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 07d6468477..41612efcac 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -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 ] } diff --git a/LICENSE b/LICENSE index 9d61ce6c11..ba3fe0ff35 100644 --- a/LICENSE +++ b/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. diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx index f426f0cbae..c172f367c9 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/airtable/page.tsx @@ -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"; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx index 99e56933eb..19828a3876 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/google-sheets/page.tsx @@ -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"; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts new file mode 100644 index 0000000000..3039099837 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/lib/surveys.ts @@ -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 => + 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(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)], + } + )() +); diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx index 9e65336db7..130fddf8c7 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/notion/page.tsx @@ -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"; diff --git a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx index b9d4b0f964..1a72b35784 100644 --- a/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/integrations/slack/page.tsx @@ -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"; diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx new file mode 100644 index 0000000000..b7f6303685 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.test.tsx @@ -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" + ); + }); +}); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx index 4f6a177dd7..d7ba202c3c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx @@ -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 && ( diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.tsx index 1ed3bd21bc..e402d9fd59 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/components/SettingsCard.tsx @@ -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 = ({ diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx index 634c3206b2..c1eb5af132 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns.tsx @@ -200,13 +200,6 @@ export const generateResponseTableColumns = ( {t("environments.surveys.responses.how_to_identify_users")} - - {t("common.link_surveys")} - {" "} - or{" "} { 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"), diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index 3f6050954c..81cc7a8e73 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -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)) { diff --git a/apps/web/app/(redirects)/organizations/[organizationId]/route.ts b/apps/web/app/(redirects)/organizations/[organizationId]/route.ts index 6d9620b42c..9f81f7cd2d 100644 --- a/apps/web/app/(redirects)/organizations/[organizationId]/route.ts +++ b/apps/web/app/(redirects)/organizations/[organizationId]/route.ts @@ -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); diff --git a/apps/web/app/(redirects)/projects/[projectId]/route.ts b/apps/web/app/(redirects)/projects/[projectId]/route.ts index 4c28c35fff..ba4f230426 100644 --- a/apps/web/app/(redirects)/projects/[projectId]/route.ts +++ b/apps/web/app/(redirects)/projects/[projectId]/route.ts @@ -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); diff --git a/apps/web/app/api/v1/auth.ts b/apps/web/app/api/v1/auth.ts index 0fe9090188..eeb67bca90 100644 --- a/apps/web/app/api/v1/auth.ts +++ b/apps/web/app/api/v1/auth.ts @@ -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 => { 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; } diff --git a/apps/web/app/api/v1/lib/api-key.ts b/apps/web/app/api/v1/lib/api-key.ts index c90e80d216..62a69b315c 100644 --- a/apps/web/app/api/v1/lib/api-key.ts +++ b/apps/web/app/api/v1/lib/api-key.ts @@ -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 => { 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)], } diff --git a/apps/web/app/api/v1/management/me/lib/utils.ts b/apps/web/app/api/v1/management/me/lib/utils.ts new file mode 100644 index 0000000000..f2aa079838 --- /dev/null +++ b/apps/web/app/api/v1/management/me/lib/utils.ts @@ -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; +}; diff --git a/apps/web/app/api/v1/management/me/route.ts b/apps/web/app/api/v1/management/me/route.ts index a12c337a22..e43eff3bed 100644 --- a/apps/web/app/api/v1/management/me/route.ts +++ b/apps/web/app/api/v1/management/me/route.ts @@ -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"; diff --git a/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts b/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts new file mode 100644 index 0000000000..f2943a511c --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/contacts/[userId]/attributes/route.ts @@ -0,0 +1,6 @@ +import { + OPTIONS, + PUT, +} from "@/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route"; + +export { OPTIONS, PUT }; diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.ts b/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.ts new file mode 100644 index 0000000000..a7c02dad94 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/displays/lib/contact.ts @@ -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 => + cache( + async () => { + const contact = await prisma.contact.findFirst({ + where: { + id, + }, + select: { + id: true, + }, + }); + + return !!contact; + }, + [`doesContactExistDisplaysApiV2-${id}`], + { + tags: [contactCache.tag.byId(id)], + } + )() +); diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.ts b/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.ts new file mode 100644 index 0000000000..c6ddd6479f --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/displays/lib/display.ts @@ -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; + } +}; diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/route.ts b/apps/web/app/api/v2/client/[environmentId]/displays/route.ts new file mode 100644 index 0000000000..bddc7cb7de --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/displays/route.ts @@ -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 => { + return responses.successResponse({}, true); +}; + +export const POST = async (request: Request, context: Context): Promise => { + 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); + } + } +}; diff --git a/apps/web/app/api/v2/client/[environmentId]/displays/types/display.ts b/apps/web/app/api/v2/client/[environmentId]/displays/types/display.ts new file mode 100644 index 0000000000..55df1a3392 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/displays/types/display.ts @@ -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; diff --git a/apps/web/app/api/v2/client/[environmentId]/environment/route.ts b/apps/web/app/api/v2/client/[environmentId]/environment/route.ts new file mode 100644 index 0000000000..2a486943cd --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/environment/route.ts @@ -0,0 +1,3 @@ +import { GET, OPTIONS } from "@/app/api/v1/client/[environmentId]/environment/route"; + +export { OPTIONS, GET }; diff --git a/apps/web/app/api/v2/client/[environmentId]/identify/contacts/[userId]/route.ts b/apps/web/app/api/v2/client/[environmentId]/identify/contacts/[userId]/route.ts new file mode 100644 index 0000000000..b81a65e3b3 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/identify/contacts/[userId]/route.ts @@ -0,0 +1,6 @@ +import { + GET, + OPTIONS, +} from "@/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route"; + +export { GET, OPTIONS }; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/[responseId]/route.ts b/apps/web/app/api/v2/client/[environmentId]/responses/[responseId]/route.ts new file mode 100644 index 0000000000..2e177bd163 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/[responseId]/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, PUT } from "@/app/api/v1/client/[environmentId]/responses/[responseId]/route"; + +export { OPTIONS, PUT }; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts new file mode 100644 index 0000000000..2fb4ec337c --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/contact.ts @@ -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)], + } + )() +); diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts new file mode 100644 index 0000000000..2f1eee4c73 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/lib/response.ts @@ -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 => { + 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; + } +}; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/route.ts b/apps/web/app/api/v2/client/[environmentId]/responses/route.ts new file mode 100644 index 0000000000..b695171b74 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/route.ts @@ -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 => { + return responses.successResponse({}, true); +}; + +export const POST = async (request: Request, context: Context): Promise => { + 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); +}; diff --git a/apps/web/app/api/v2/client/[environmentId]/responses/types/response.ts b/apps/web/app/api/v2/client/[environmentId]/responses/types/response.ts new file mode 100644 index 0000000000..d86a27ed34 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/responses/types/response.ts @@ -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; diff --git a/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts b/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts new file mode 100644 index 0000000000..cb0a14158f --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/storage/local/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/local/route"; + +export { OPTIONS, POST }; diff --git a/apps/web/app/api/v2/client/[environmentId]/storage/route.ts b/apps/web/app/api/v2/client/[environmentId]/storage/route.ts new file mode 100644 index 0000000000..58d117cceb --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/storage/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, POST } from "@/app/api/v1/client/[environmentId]/storage/route"; + +export { OPTIONS, POST }; diff --git a/apps/web/app/api/v2/client/[environmentId]/user/route.ts b/apps/web/app/api/v2/client/[environmentId]/user/route.ts new file mode 100644 index 0000000000..0198ac1f99 --- /dev/null +++ b/apps/web/app/api/v2/client/[environmentId]/user/route.ts @@ -0,0 +1,3 @@ +import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route"; + +export { POST, OPTIONS }; diff --git a/apps/web/app/api/v2/management/responses/[responseId]/route.ts b/apps/web/app/api/v2/management/responses/[responseId]/route.ts new file mode 100644 index 0000000000..40f1cd7bbc --- /dev/null +++ b/apps/web/app/api/v2/management/responses/[responseId]/route.ts @@ -0,0 +1,3 @@ +import { DELETE, GET, PUT } from "@/modules/api/v2/management/responses/[responseId]/route"; + +export { GET, PUT, DELETE }; diff --git a/apps/web/app/api/v2/management/responses/route.ts b/apps/web/app/api/v2/management/responses/route.ts new file mode 100644 index 0000000000..14891ecfd5 --- /dev/null +++ b/apps/web/app/api/v2/management/responses/route.ts @@ -0,0 +1,3 @@ +import { GET, POST } from "@/modules/api/v2/management/responses/route"; + +export { GET, POST }; diff --git a/apps/web/app/lib/api/apiHelper.ts b/apps/web/app/lib/api/apiHelper.ts deleted file mode 100644 index 3c43026d8b..0000000000 --- a/apps/web/app/lib/api/apiHelper.ts +++ /dev/null @@ -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; -}; diff --git a/apps/web/app/lib/api/response.ts b/apps/web/app/lib/api/response.ts index ac4b9c3f93..91714161a2 100644 --- a/apps/web/app/lib/api/response.ts +++ b/apps/web/app/lib/api/response.ts @@ -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, diff --git a/apps/web/app/middleware/endpoint-validator.ts b/apps/web/app/middleware/endpoint-validator.ts index 6462ac728d..ef079a6ba7 100644 --- a/apps/web/app/middleware/endpoint-validator.ts +++ b/apps/web/app/middleware/endpoint-validator.ts @@ -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); diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index f951670742..080beca153 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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 => { + 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*", ], }; diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx index 629db33b2f..93b3b7062f 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/components/SurveyLinkDisplay.tsx @@ -8,7 +8,7 @@ export const SurveyLinkDisplay = ({ surveyUrl }: SurveyLinkDisplayProps) => { return ( ); diff --git a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx index 69bb2f09d8..33528d9f51 100644 --- a/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx +++ b/apps/web/modules/analysis/components/ShareSurveyLink/index.tsx @@ -71,7 +71,7 @@ export const ShareSurveyLink = ({ return (
- +
- @@ -233,7 +238,11 @@ export const EmailCustomizationSettings = ({
-
Logo key; + +vi.mock("@formbricks/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + FB_LOGO_URL: "https://example.com/mock-logo.png", + IMPRINT_URL: "https://example.com/imprint", + PRIVACY_URL: "https://example.com/privacy", + IMPRINT_ADDRESS: "Imprint Address", +})); + +const defaultProps = { + children:
Test Content
, + logoUrl: "https://example.com/custom-logo.png", + t: mockTranslate, +}; + +describe("EmailTemplate", () => { + beforeEach(() => { + cleanup(); + }); + + it("renders the default logo if no custom logo is provided", async () => { + const emailTemplateElement = await EmailTemplate({ + children:
Test Content
, + logoUrl: undefined, + t: mockTranslate, + }); + + render(emailTemplateElement); + + const logoImage = screen.getByTestId("default-logo-image"); + expect(logoImage).toBeInTheDocument(); + expect(logoImage).toHaveAttribute("src", "https://example.com/mock-logo.png"); + }); + + it("renders the custom logo if provided", async () => { + const emailTemplateElement = await EmailTemplate({ + ...defaultProps, + }); + + render(emailTemplateElement); + + const logoImage = screen.getByTestId("logo-image"); + expect(logoImage).toBeInTheDocument(); + expect(logoImage).toHaveAttribute("src", "https://example.com/custom-logo.png"); + }); + + it("renders the children content", async () => { + const emailTemplateElement = await EmailTemplate({ + ...defaultProps, + }); + + render(emailTemplateElement); + + expect(screen.getByTestId("child-text")).toBeInTheDocument(); + }); + + it("renders the imprint and privacy policy links if provided", async () => { + const emailTemplateElement = await EmailTemplate({ + ...defaultProps, + }); + + render(emailTemplateElement); + + expect(screen.getByText("emails.imprint")).toBeInTheDocument(); + expect(screen.getByText("emails.privacy_policy")).toBeInTheDocument(); + }); + + it("renders the imprint address if provided", async () => { + const emailTemplateElement = await EmailTemplate({ + ...defaultProps, + }); + + render(emailTemplateElement); + + expect(screen.getByText("emails.email_template_text_1")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/email/components/email-template.tsx b/apps/web/modules/email/components/email-template.tsx index 87811b7669..92ad28ab29 100644 --- a/apps/web/modules/email/components/email-template.tsx +++ b/apps/web/modules/email/components/email-template.tsx @@ -1,15 +1,15 @@ import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components"; import { TFnType } from "@tolgee/react"; -import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants"; +import React from "react"; +import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants"; -const fbLogoUrl = - "https://s3.eu-central-1.amazonaws.com/listmonk-formbricks/Formbricks-Light-transparent.png"; +const fbLogoUrl = FB_LOGO_URL; const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email"; interface EmailTemplateProps { - children: React.ReactNode; - logoUrl?: string; - t: TFnType; + readonly children: React.ReactNode; + readonly logoUrl?: string; + readonly t: TFnType; } export async function EmailTemplate({ @@ -30,10 +30,15 @@ export async function EmailTemplate({
{isDefaultLogo ? ( - Logo + Logo ) : ( - Logo + Logo )}
diff --git a/apps/web/modules/email/emails/survey/follow-up.test.tsx b/apps/web/modules/email/emails/survey/follow-up.test.tsx new file mode 100644 index 0000000000..f6b9c62321 --- /dev/null +++ b/apps/web/modules/email/emails/survey/follow-up.test.tsx @@ -0,0 +1,88 @@ +import { getTranslate } from "@/tolgee/server"; +import "@testing-library/jest-dom/vitest"; +import { render, screen } from "@testing-library/react"; +import { DefaultParamType, TFnType, TranslationKey } from "@tolgee/react/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { FollowUpEmail } from "./follow-up"; + +vi.mock("@formbricks/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + FB_LOGO_URL: "https://example.com/mock-logo.png", + IMPRINT_URL: "https://example.com/imprint", + PRIVACY_URL: "https://example.com/privacy", + IMPRINT_ADDRESS: "Imprint Address", +})); + +vi.mock("@/tolgee/server", () => ({ + getTranslate: vi.fn(), +})); + +const defaultProps = { + html: "

Test HTML Content

", + logoUrl: "https://example.com/custom-logo.png", +}; + +describe("FollowUpEmail", () => { + beforeEach(() => { + vi.mocked(getTranslate).mockResolvedValue( + ((key: string) => key) as TFnType + ); + }); + + it("renders the default logo if no custom logo is provided", async () => { + const followUpEmailElement = await FollowUpEmail({ + ...defaultProps, + logoUrl: undefined, + }); + + render(followUpEmailElement); + + const logoImage = screen.getByAltText("Logo"); + expect(logoImage).toBeInTheDocument(); + expect(logoImage).toHaveAttribute("src", "https://example.com/mock-logo.png"); + }); + + it("renders the custom logo if provided", async () => { + const followUpEmailElement = await FollowUpEmail({ + ...defaultProps, + }); + + render(followUpEmailElement); + + const logoImage = screen.getByAltText("Logo"); + expect(logoImage).toBeInTheDocument(); + expect(logoImage).toHaveAttribute("src", "https://example.com/custom-logo.png"); + }); + + it("renders the HTML content", async () => { + const followUpEmailElement = await FollowUpEmail({ + ...defaultProps, + }); + + render(followUpEmailElement); + + expect(screen.getByText("Test HTML Content")).toBeInTheDocument(); + }); + + it("renders the imprint and privacy policy links if provided", async () => { + const followUpEmailElement = await FollowUpEmail({ + ...defaultProps, + }); + + render(followUpEmailElement); + + expect(screen.getByText("emails.imprint")).toBeInTheDocument(); + expect(screen.getByText("emails.privacy_policy")).toBeInTheDocument(); + }); + + it("renders the imprint address if provided", async () => { + const followUpEmailElement = await FollowUpEmail({ + ...defaultProps, + }); + + render(followUpEmailElement); + + expect(screen.getByText("emails.powered_by_formbricks")).toBeInTheDocument(); + expect(screen.getByText("Imprint Address")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/modules/email/emails/survey/follow-up.tsx b/apps/web/modules/email/emails/survey/follow-up.tsx index 613a2575cf..fed887ea88 100644 --- a/apps/web/modules/email/emails/survey/follow-up.tsx +++ b/apps/web/modules/email/emails/survey/follow-up.tsx @@ -1,15 +1,22 @@ import { getTranslate } from "@/tolgee/server"; import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components"; import dompurify from "isomorphic-dompurify"; -import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants"; +import React from "react"; +import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants"; + +const fbLogoUrl = FB_LOGO_URL; +const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email"; interface FollowUpEmailProps { - html: string; - logoUrl?: string; + readonly html: string; + readonly logoUrl?: string; } export async function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): Promise { const t = await getTranslate(); + console.log(t("emails.imprint")); + const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl; + return ( @@ -18,11 +25,15 @@ export async function FollowUpEmail({ html, logoUrl }: FollowUpEmailProps): Prom style={{ fontFamily: "'Jost', 'Helvetica Neue', 'Segoe UI', 'Helvetica', 'sans-serif'", }}> - {logoUrl && ( -
+
+ {isDefaultLogo ? ( + + Logo + + ) : ( Logo -
- )} + )} +
- {t("emails.survey_response_finished_email_turn_off_notifications")} - {t("emails.survey_response_finished_email_this_form")} + {t("emails.survey_response_finished_email_turn_off_notifications_for_this_form")} diff --git a/apps/web/modules/integrations/webhooks/components/webhook-settings-tab.tsx b/apps/web/modules/integrations/webhooks/components/webhook-settings-tab.tsx index bb4000a0f0..9645463fa3 100644 --- a/apps/web/modules/integrations/webhooks/components/webhook-settings-tab.tsx +++ b/apps/web/modules/integrations/webhooks/components/webhook-settings-tab.tsx @@ -21,14 +21,14 @@ import { TSurvey } from "@formbricks/types/surveys/types"; import { deleteWebhookAction, testEndpointAction, updateWebhookAction } from "../actions"; import { TWebhookInput } from "../types/webhooks"; -interface ActionSettingsTabProps { +interface WebhookSettingsTabProps { webhook: Webhook; surveys: TSurvey[]; setOpen: (v: boolean) => void; isReadOnly: boolean; } -export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: ActionSettingsTabProps) => { +export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: WebhookSettingsTabProps) => { const { t } = useTranslate(); const router = useRouter(); const { register, handleSubmit } = useForm({ @@ -219,7 +219,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: Ac
- {webhook.source === "user" && !isReadOnly && ( + {!isReadOnly && (