From e1de1d4f28b32aa914b8a3966d7bd7c2275e89da Mon Sep 17 00:00:00 2001 From: Matti Nannt Date: Thu, 28 May 2026 11:24:35 +0200 Subject: [PATCH 1/4] chore: resolve blocker + clear-path high SonarQube issues (#8122) Co-authored-by: Claude Opus 4.7 (1M context) --- .../(analysis)/summary/lib/surveySummary.ts | 38 +++++++++---------- .../v1/management/responses/lib/response.ts | 28 +++++--------- apps/web/lib/constants.ts | 2 +- apps/web/lib/response/service.ts | 20 +++++----- apps/web/lib/utils/promises.test.ts | 2 +- .../management/contacts/lib/contacts.test.ts | 6 --- .../modules/ee/contacts/types/contact.test.ts | 6 +-- apps/web/modules/ee/quotas/lib/utils.test.ts | 7 ++-- .../web/modules/ee/sso/lib/tests/team.test.ts | 8 ++-- .../modules/survey/lib/action-class.test.ts | 3 +- .../survey-bg-selector-tab.tsx | 1 + apps/web/playwright/onboarding.spec.ts | 1 + apps/web/vitestSetup.ts | 2 +- packages/js-core/src/lib/common/config.ts | 2 +- .../lib/survey/tests/no-code-action.test.ts | 29 ++++++++------ .../src/lib/survey/tests/widget.test.ts | 12 ++++-- 16 files changed, 82 insertions(+), 85 deletions(-) diff --git a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts index 06a49a0420b3..d3d5f7598b9d 100644 --- a/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts +++ b/apps/web/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/surveySummary.ts @@ -1111,27 +1111,23 @@ export const getResponsesForSummary = reactCache( skip: offset, }); - const transformedResponses: TSurveySummaryResponse[] = await Promise.all( - responses.map((responsePrisma) => { - return { - id: responsePrisma.id, - data: (responsePrisma.data ?? {}) as TResponseData, - updatedAt: responsePrisma.updatedAt, - contact: responsePrisma.contact - ? { - id: responsePrisma.contact.id as string, - userId: responsePrisma.contact.attributes.find( - (attribute) => attribute.attributeKey.key === "userId" - )?.value as string, - } - : null, - contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes, - language: responsePrisma.language, - ttc: (responsePrisma.ttc ?? {}) as TResponseTtc, - finished: responsePrisma.finished, - }; - }) - ); + const transformedResponses: TSurveySummaryResponse[] = responses.map((responsePrisma) => ({ + id: responsePrisma.id, + data: (responsePrisma.data ?? {}) as TResponseData, + updatedAt: responsePrisma.updatedAt, + contact: responsePrisma.contact + ? { + id: responsePrisma.contact.id as string, + userId: responsePrisma.contact.attributes.find( + (attribute) => attribute.attributeKey.key === "userId" + )?.value as string, + } + : null, + contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes, + language: responsePrisma.language, + ttc: (responsePrisma.ttc ?? {}) as TResponseTtc, + finished: responsePrisma.finished, + })); return transformedResponses; } catch (error) { diff --git a/apps/web/app/api/v1/management/responses/lib/response.ts b/apps/web/app/api/v1/management/responses/lib/response.ts index 438a3961ccfc..096269a55526 100644 --- a/apps/web/app/api/v1/management/responses/lib/response.ts +++ b/apps/web/app/api/v1/management/responses/lib/response.ts @@ -161,15 +161,11 @@ export const getResponsesByWorkspaceIds = reactCache( skip: offset ? offset : undefined, }); - const transformedResponses: TResponse[] = await Promise.all( - responses.map((responsePrisma) => { - return { - ...responsePrisma, - contact: getResponseContact(responsePrisma), - tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), - }; - }) - ); + const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({ + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + })); return transformedResponses; } catch (error) { @@ -205,15 +201,11 @@ export const getResponses = reactCache( skip: offset, }); - const transformedResponses: TResponse[] = await Promise.all( - responses.map((responsePrisma) => { - return { - ...responsePrisma, - contact: getResponseContact(responsePrisma), - tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), - }; - }) - ); + const transformedResponses: TResponse[] = responses.map((responsePrisma) => ({ + ...responsePrisma, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + })); return transformedResponses; } catch (error) { diff --git a/apps/web/lib/constants.ts b/apps/web/lib/constants.ts index 7ea17900e2ce..865af5884b5c 100644 --- a/apps/web/lib/constants.ts +++ b/apps/web/lib/constants.ts @@ -237,4 +237,4 @@ export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1"; export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400; // Control hash for constant-time password verification to prevent timing attacks. Used when user doesn't exist to maintain consistent verification timing -export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q"; +export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q"; //NOSONAR not a real password hash, used only for timing-safe comparison diff --git a/apps/web/lib/response/service.ts b/apps/web/lib/response/service.ts index 52604d84107e..aebaf937aecf 100644 --- a/apps/web/lib/response/service.ts +++ b/apps/web/lib/response/service.ts @@ -338,17 +338,15 @@ export const getResponses = reactCache( skip: offset, }); - const transformedResponses: TResponseWithQuotas[] = await Promise.all( - responses.map((responsePrisma) => { - const { quotaLinks, ...response } = responsePrisma; - return { - ...response, - contact: getResponseContact(responsePrisma), - tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), - quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota), - }; - }) - ); + const transformedResponses: TResponseWithQuotas[] = responses.map((responsePrisma) => { + const { quotaLinks, ...response } = responsePrisma; + return { + ...response, + contact: getResponseContact(responsePrisma), + tags: responsePrisma.tags.map((tagPrisma: { tag: TTag }) => tagPrisma.tag), + quotas: quotaLinks.map((quotaLinkPrisma) => quotaLinkPrisma.quota), + }; + }); return transformedResponses; } catch (error) { diff --git a/apps/web/lib/utils/promises.test.ts b/apps/web/lib/utils/promises.test.ts index 80680a175934..31b8c69d3118 100644 --- a/apps/web/lib/utils/promises.test.ts +++ b/apps/web/lib/utils/promises.test.ts @@ -9,7 +9,7 @@ describe("promises utilities", () => { const promise = delay(delayTime); vi.advanceTimersByTime(delayTime); - await promise; + await expect(promise).resolves.toBeUndefined(); vi.useRealTimers(); }); diff --git a/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.test.ts b/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.test.ts index a3eb32a1c548..9bd58ddd6c06 100644 --- a/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.test.ts +++ b/apps/web/modules/ee/contacts/api/v1/management/contacts/lib/contacts.test.ts @@ -73,10 +73,4 @@ describe("getContacts", () => { where: { workspaceId: { in: mockWorkspaceIds } }, }); }); - - test("should get contacts", async () => { - vi.mocked(prisma.contact.findMany).mockResolvedValue(mockContacts); - - await getContacts(mockWorkspaceIds); - }); }); diff --git a/apps/web/modules/ee/contacts/types/contact.test.ts b/apps/web/modules/ee/contacts/types/contact.test.ts index 2c938e34b530..9207ada766c0 100644 --- a/apps/web/modules/ee/contacts/types/contact.test.ts +++ b/apps/web/modules/ee/contacts/types/contact.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { ZodError } from "zod"; import { ZContact, @@ -648,10 +648,10 @@ describe("validateUniqueAttributeKeys", () => { }, ]; const mockCtx = { - addIssue: () => {}, + addIssue: vi.fn(), } as any; - // Should not throw or call addIssue validateUniqueAttributeKeys(attributes, mockCtx); + expect(mockCtx.addIssue).not.toHaveBeenCalled(); }); test("should fail validation for duplicate attribute keys", () => { diff --git a/apps/web/modules/ee/quotas/lib/utils.test.ts b/apps/web/modules/ee/quotas/lib/utils.test.ts index c2ee5563c601..c34657bc04ee 100644 --- a/apps/web/modules/ee/quotas/lib/utils.test.ts +++ b/apps/web/modules/ee/quotas/lib/utils.test.ts @@ -292,9 +292,10 @@ describe("Quota Utils", () => { test("should handle empty quota arrays within transaction", async () => { await upsertResponseQuotaLinks(mockResponseId, [], [], [], asTx(mockTx)); - // Verify transaction was called even with empty arrays - // expect(mockTx).toHaveBeenCalledTimes(1); - // expect(mockTx).toHaveBeenCalledWith(expect.any(Function)); + // deleteMany always runs; create/update are skipped when arrays are empty + expect(mockTx.responseQuotaLink.deleteMany).toHaveBeenCalledTimes(1); + expect(mockTx.responseQuotaLink.createMany).not.toHaveBeenCalled(); + expect(mockTx.responseQuotaLink.updateMany).not.toHaveBeenCalled(); }); test("should execute correct operations within transaction", async () => { diff --git a/apps/web/modules/ee/sso/lib/tests/team.test.ts b/apps/web/modules/ee/sso/lib/tests/team.test.ts index 5f6bec9f6dbc..113375453faa 100644 --- a/apps/web/modules/ee/sso/lib/tests/team.test.ts +++ b/apps/web/modules/ee/sso/lib/tests/team.test.ts @@ -125,14 +125,16 @@ describe("Team Management", () => { describe("error handling", () => { test("handles missing default team gracefully", async () => { vi.mocked(prisma.team.findUnique).mockResolvedValue(null); - await createDefaultTeamMembership(MOCK_IDS.userId); + await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined(); + expect(prisma.teamUser.upsert).not.toHaveBeenCalled(); }); test("handles missing organization membership gracefully", async () => { vi.mocked(prisma.team.findUnique).mockResolvedValue(MOCK_DEFAULT_TEAM); vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null); - await createDefaultTeamMembership(MOCK_IDS.userId); + await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined(); + expect(prisma.teamUser.upsert).not.toHaveBeenCalled(); }); test("handles database errors gracefully", async () => { @@ -140,7 +142,7 @@ describe("Team Management", () => { vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(MOCK_ORGANIZATION_MEMBERSHIP); vi.mocked(prisma.teamUser.upsert).mockRejectedValue(new Error("Database error")); - await createDefaultTeamMembership(MOCK_IDS.userId); + await expect(createDefaultTeamMembership(MOCK_IDS.userId)).resolves.toBeUndefined(); }); }); }); diff --git a/apps/web/modules/survey/lib/action-class.test.ts b/apps/web/modules/survey/lib/action-class.test.ts index 952bafe6c83c..1b5293d655d3 100644 --- a/apps/web/modules/survey/lib/action-class.test.ts +++ b/apps/web/modules/survey/lib/action-class.test.ts @@ -103,6 +103,7 @@ describe("getActionClasses", () => { // We need to import the actual react cache to test it with vi.spyOn if we weren't mocking it. // However, since we are mocking it to be a pass-through, we just check if our main cache is called. - await getActionClasses(workspaceId); + const result = await getActionClasses(workspaceId); + expect(result).toEqual(mockActionClasses); }); }); diff --git a/apps/web/modules/ui/components/background-styling-card/survey-bg-selector-tab.tsx b/apps/web/modules/ui/components/background-styling-card/survey-bg-selector-tab.tsx index 03bf537a55f0..1e2bbda6f524 100644 --- a/apps/web/modules/ui/components/background-styling-card/survey-bg-selector-tab.tsx +++ b/apps/web/modules/ui/components/background-styling-card/survey-bg-selector-tab.tsx @@ -87,6 +87,7 @@ export const SurveyBgSelectorTab = ({ if (isUnsplashConfigured) { return ; } + return null; default: return null; } diff --git a/apps/web/playwright/onboarding.spec.ts b/apps/web/playwright/onboarding.spec.ts index fd94afe88804..c45a82e9d239 100644 --- a/apps/web/playwright/onboarding.spec.ts +++ b/apps/web/playwright/onboarding.spec.ts @@ -60,5 +60,6 @@ test.describe("CX Onboarding", async () => { await page.getByRole("button", { name: "Save & Close" }).click(); await page.waitForURL(/\/workspaces\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/); + await expect(page).toHaveURL(/\/workspaces\/[^/]+\/surveys\/[^/]+\/summary(\?.*)?$/); }); }); diff --git a/apps/web/vitestSetup.ts b/apps/web/vitestSetup.ts index 33692d54e7a8..06defe497f87 100644 --- a/apps/web/vitestSetup.ts +++ b/apps/web/vitestSetup.ts @@ -247,6 +247,6 @@ vi.mock("@/lib/constants", async (importOriginal) => { RATE_LIMITING_DISABLED: false, TELEMETRY_DISABLED: false, PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30, - CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q", + CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q", //NOSONAR mirrors production CONTROL_HASH, not a real password hash }; }); diff --git a/packages/js-core/src/lib/common/config.ts b/packages/js-core/src/lib/common/config.ts index f85fc66504c6..aaa1f6dbcb74 100644 --- a/packages/js-core/src/lib/common/config.ts +++ b/packages/js-core/src/lib/common/config.ts @@ -30,7 +30,7 @@ export class Config { }, }; - void this.saveToStorage(); + this.saveToStorage(); } public get(): TConfig { diff --git a/packages/js-core/src/lib/survey/tests/no-code-action.test.ts b/packages/js-core/src/lib/survey/tests/no-code-action.test.ts index 5aa09155ab51..d18418f27e8e 100644 --- a/packages/js-core/src/lib/survey/tests/no-code-action.test.ts +++ b/packages/js-core/src/lib/survey/tests/no-code-action.test.ts @@ -360,8 +360,11 @@ describe("no-code-event-listeners file", () => { test("addExitIntentListener does not add if document is undefined", () => { vi.stubGlobal("document", undefined); - addExitIntentListener(); - // No explicit expect, passes if no error. querySelector would not be called. + expect(() => { + addExitIntentListener(); + }).not.toThrow(); + expect(querySelectorMock).not.toHaveBeenCalled(); + expect(addEventListenerMock).not.toHaveBeenCalled(); }); test("addExitIntentListener does not add if body is not found", () => { @@ -426,8 +429,9 @@ describe("no-code-event-listeners file", () => { test("addScrollDepthListener does not add if window is undefined", () => { vi.stubGlobal("window", undefined); - addScrollDepthListener(); - // No explicit expect. Passes if no error. + expect(() => { + addScrollDepthListener(); + }).not.toThrow(); }); test("addScrollDepthListener does not re-add listener if already added", () => { @@ -593,11 +597,9 @@ describe("checkPageUrl additional cases", () => { describe("addPageUrlEventListeners additional cases", () => { test("addPageUrlEventListeners does not add listeners if window is undefined", () => { vi.stubGlobal("window", undefined); - addPageUrlEventListeners(); // Call the function - // No explicit expect needed, the test passes if no error is thrown - // and no listeners were attempted to be added to an undefined window. - // We can also assert that isHistoryPatched remains false if it's exported and settable for testing. - // For now, we assume it's an internal detail not directly testable without more mocks. + expect(() => { + addPageUrlEventListeners(); + }).not.toThrow(); }); test("addPageUrlEventListeners does not re-add listeners if already added", () => { @@ -670,15 +672,18 @@ describe("addPageUrlEventListeners additional cases", () => { describe("removePageUrlEventListeners additional cases", () => { test("removePageUrlEventListeners does nothing if window is undefined", () => { vi.stubGlobal("window", undefined); - removePageUrlEventListeners(); - // No explicit expect. Passes if no error. + expect(() => { + removePageUrlEventListeners(); + }).not.toThrow(); }); test("removePageUrlEventListeners does nothing if listeners were not added", () => { const removeEventListenerMock = vi.fn(); vi.stubGlobal("window", { removeEventListener: removeEventListenerMock }); // Assuming listeners are not added yet (arePageUrlEventListenersAdded is false) - removePageUrlEventListeners(); + expect(() => { + removePageUrlEventListeners(); + }).not.toThrow(); (window.removeEventListener as Mock).mockRestore(); }); }); diff --git a/packages/js-core/src/lib/survey/tests/widget.test.ts b/packages/js-core/src/lib/survey/tests/widget.test.ts index f9e2e185bcfe..fe9a94b7e5dd 100644 --- a/packages/js-core/src/lib/survey/tests/widget.test.ts +++ b/packages/js-core/src/lib/survey/tests/widget.test.ts @@ -96,8 +96,13 @@ describe("widget-file", () => { vi.restoreAllMocks(); }); - test("setIsSurveyRunning toggles internal state (covered by usage in other tests)", () => { - widget.setIsSurveyRunning(true); + test("setIsSurveyRunning toggles internal state without throwing", () => { + expect(() => { + widget.setIsSurveyRunning(true); + }).not.toThrow(); + expect(() => { + widget.setIsSurveyRunning(false); + }).not.toThrow(); }); test("triggerSurvey skips if shouldDisplayBasedOnPercentage returns false", async () => { @@ -111,7 +116,8 @@ describe("widget-file", () => { ); }); - test("triggerSurvey calls renderWidget if displayPercentage is not an issue", async () => { + test("triggerSurvey short-circuits via renderWidget when a survey is already running", async () => { + widget.setIsSurveyRunning(true); (shouldDisplayBasedOnPercentage as Mock).mockReturnValueOnce(true); await widget.triggerSurvey(mockSurvey); From 0c614a6a1f96bc7118fb1e7dbd95a616a2d44632 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 09:40:57 +0000 Subject: [PATCH 2/4] chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#8082) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Matti Nannt --- package.json | 2 +- pnpm-lock.yaml | 132 +++++++++++++++++++++++++++---------------------- 2 files changed, 75 insertions(+), 59 deletions(-) diff --git a/package.json b/package.json index 8206479a8ab8..8f71a91b4c41 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "lint-staged": "16.4.0", "rimraf": "6.1.3", "tsx": "4.21.0", - "turbo": "2.8.21" + "turbo": "2.9.14" }, "lint-staged": { "(apps|packages)/**/*.{js,ts,jsx,tsx}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34eb28a16357..1fa4bf3995f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,8 +65,8 @@ importers: specifier: 4.21.0 version: 4.21.0 turbo: - specifier: 2.8.21 - version: 2.8.21 + specifier: 2.9.14 + version: 2.9.14 apps/storybook: devDependencies: @@ -624,7 +624,7 @@ importers: version: 10.1.8(eslint@8.57.1) eslint-config-turbo: specifier: 2.8.21 - version: 2.8.21(eslint@8.57.1)(turbo@2.8.21) + version: 2.8.21(eslint@8.57.1)(turbo@2.9.14) eslint-plugin-react: specifier: 7.37.5 version: 7.37.5(eslint@8.57.1) @@ -1247,6 +1247,7 @@ packages: '@aws-sdk/core@3.974.10': resolution: {integrity: sha512-ZGFFlYynBR78Y/F8b/7y4i4sgW/iGwJSjoM7AZo5Et6vyr4/L0bunN+uzKMsvecCZyqcPp4RRK7Rs17l0kMujg==} engines: {node: '>=20.0.0'} + deprecated: Deprecated due to an error deserialization bug in JSON 1.0 protocol services, see https://github.com/aws/aws-sdk-js-v3/pull/8031. Newer version available. '@aws-sdk/crc64-nvme@3.972.4': resolution: {integrity: sha512-HKZIZLbRyvzo/bXZU7Zmk6XqU+1C9DjI56xd02vwuDIxedxBEqP17t9ExhbP9QFeNq/a3l9GOcyirFXxmbDhmw==} @@ -5819,33 +5820,33 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@turbo/darwin-64@2.8.21': - resolution: {integrity: sha512-kfGoM0Iw8ZNZpbds+4IzOe0hjvHldqJwUPRAjXJi3KBxg/QOZL95N893SRoMtf2aJ+jJ3dk32yPkp8rvcIjP9g==} + '@turbo/darwin-64@2.9.14': + resolution: {integrity: sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.8.21': - resolution: {integrity: sha512-o9HEflxUEyr987x0cTUzZBhDOyL6u95JmdmlkH2VyxAw7zq2sdtM5e72y9ufv2N5SIoOBw1fVn9UES5VY5H6vQ==} + '@turbo/darwin-arm64@2.9.14': + resolution: {integrity: sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.8.21': - resolution: {integrity: sha512-uTxlCcXWy5h1fSSymP8XSJ+AudzEHMDV3IDfKX7+DGB8kgJ+SLoTUAH7z4OFA7I/l2sznz0upPdbNNZs91YMag==} + '@turbo/linux-64@2.9.14': + resolution: {integrity: sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==} cpu: [x64] os: [linux] - '@turbo/linux-arm64@2.8.21': - resolution: {integrity: sha512-cdHIcxNcihHHkCHp0Y4Zb60K4Qz+CK4xw1gb6s/t/9o4SMeMj+hTBCtoW6QpPnl9xPYmxuTou8Zw6+cylTnREg==} + '@turbo/linux-arm64@2.9.14': + resolution: {integrity: sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==} cpu: [arm64] os: [linux] - '@turbo/windows-64@2.8.21': - resolution: {integrity: sha512-/iBj4OzbqEY8CX+eaeKbBTMZv2CLXNrt0692F7HnK7LcyYwyDecaAiSET6ZzL4opT7sbwkKvzAC/fhqT3Quu1A==} + '@turbo/windows-64@2.9.14': + resolution: {integrity: sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.8.21': - resolution: {integrity: sha512-95tMA/ZbIidJFUUtkmqioQ1gf3n3I1YbRP3ZgVdWTVn2qVbkodcIdGXBKRHHrIbRsLRl99SiHi/L7IxhpZDagQ==} + '@turbo/windows-arm64@2.9.14': + resolution: {integrity: sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==} cpu: [arm64] os: [win32] @@ -6879,8 +6880,8 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} - baseline-browser-mapping@2.10.29: - resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} + baseline-browser-mapping@2.10.31: + resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==} engines: {node: '>=6.0.0'} hasBin: true @@ -7022,8 +7023,8 @@ packages: caniuse-lite@1.0.30001776: resolution: {integrity: sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==} - caniuse-lite@1.0.30001792: - resolution: {integrity: sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==} + caniuse-lite@1.0.30001793: + resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} chai@5.3.3: resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} @@ -7602,8 +7603,8 @@ packages: electron-to-chromium@1.5.267: resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} - electron-to-chromium@1.5.356: - resolution: {integrity: sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==} + electron-to-chromium@1.5.360: + resolution: {integrity: sha512-GkcBt6YYAw9SxFWn+xVar4cLVGlXVuswwtRLBozi2zp0GjXs4ZnOrqV4zbXzg35n7w81hCkyJNYicgXlVHAmBA==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -7636,8 +7637,8 @@ packages: resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} - enhanced-resolve@5.21.3: - resolution: {integrity: sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==} + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} engines: {node: '>=10.13.0'} entities@4.5.0: @@ -9529,8 +9530,9 @@ packages: node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - node-releases@2.0.44: - resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==} + node-releases@2.0.45: + resolution: {integrity: sha512-iIbHXV9eBB2nB0wa7oTsrrXq+qQt+9SIlx9AX3T96YgobtEQfis5n6TJ6vV+3QP8DwdriEAcGhARaFCu37peBg==} + engines: {node: '>=18'} nodemailer@8.0.7: resolution: {integrity: sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==} @@ -11319,8 +11321,8 @@ packages: tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - turbo@2.8.21: - resolution: {integrity: sha512-FlJ8OD5Qcp0jTAM7E4a/RhUzRNds2GzKlyxHKA6N247VLy628rrxAGlMpIXSz6VB430+TiQDJ/SMl6PL1lu6wQ==} + turbo@2.9.14: + resolution: {integrity: sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==} hasBin: true tween-functions@1.2.0: @@ -11942,6 +11944,18 @@ packages: utf-8-validate: optional: true + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -17999,22 +18013,22 @@ snapshots: '@tsconfig/node16@1.0.4': optional: true - '@turbo/darwin-64@2.8.21': + '@turbo/darwin-64@2.9.14': optional: true - '@turbo/darwin-arm64@2.8.21': + '@turbo/darwin-arm64@2.9.14': optional: true - '@turbo/linux-64@2.8.21': + '@turbo/linux-64@2.9.14': optional: true - '@turbo/linux-arm64@2.8.21': + '@turbo/linux-arm64@2.9.14': optional: true - '@turbo/windows-64@2.8.21': + '@turbo/windows-64@2.9.14': optional: true - '@turbo/windows-arm64@2.8.21': + '@turbo/windows-arm64@2.9.14': optional: true '@tybys/wasm-util@0.10.1': @@ -19242,7 +19256,7 @@ snapshots: base64id@2.0.0: {} - baseline-browser-mapping@2.10.29: {} + baseline-browser-mapping@2.10.31: {} baseline-browser-mapping@2.10.9: {} @@ -19316,10 +19330,10 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.29 - caniuse-lite: 1.0.30001792 - electron-to-chromium: 1.5.356 - node-releases: 2.0.44 + baseline-browser-mapping: 2.10.31 + caniuse-lite: 1.0.30001793 + electron-to-chromium: 1.5.360 + node-releases: 2.0.45 update-browserslist-db: 1.2.3(browserslist@4.28.2) bson@7.2.0: {} @@ -19422,7 +19436,7 @@ snapshots: caniuse-lite@1.0.30001776: {} - caniuse-lite@1.0.30001792: {} + caniuse-lite@1.0.30001793: {} chai@5.3.3: dependencies: @@ -19963,7 +19977,7 @@ snapshots: electron-to-chromium@1.5.267: {} - electron-to-chromium@1.5.356: {} + electron-to-chromium@1.5.360: {} emoji-regex@10.6.0: {} @@ -20004,7 +20018,7 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.0 - enhanced-resolve@5.21.3: + enhanced-resolve@5.21.6: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -20275,11 +20289,11 @@ snapshots: dependencies: eslint: 8.57.1 - eslint-config-turbo@2.8.21(eslint@8.57.1)(turbo@2.8.21): + eslint-config-turbo@2.8.21(eslint@8.57.1)(turbo@2.9.14): dependencies: eslint: 8.57.1 - eslint-plugin-turbo: 2.8.21(eslint@8.57.1)(turbo@2.8.21) - turbo: 2.8.21 + eslint-plugin-turbo: 2.8.21(eslint@8.57.1)(turbo@2.9.14) + turbo: 2.9.14 eslint-import-resolver-alias@1.1.2(eslint-plugin-import@2.32.0): dependencies: @@ -20490,11 +20504,11 @@ snapshots: '@microsoft/tsdoc': 0.14.2 '@microsoft/tsdoc-config': 0.16.2 - eslint-plugin-turbo@2.8.21(eslint@8.57.1)(turbo@2.8.21): + eslint-plugin-turbo@2.8.21(eslint@8.57.1)(turbo@2.9.14): dependencies: dotenv: 16.0.3 eslint: 8.57.1 - turbo: 2.8.21 + turbo: 2.9.14 eslint-plugin-unicorn@51.0.1(eslint@8.57.1): dependencies: @@ -21013,7 +21027,7 @@ snapshots: '@types/ws': 8.18.1 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.18.3 + ws: 8.20.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -22214,7 +22228,7 @@ snapshots: node-releases@2.0.27: {} - node-releases@2.0.44: {} + node-releases@2.0.45: {} nodemailer@8.0.7: {} @@ -23770,7 +23784,7 @@ snapshots: recast: 0.23.11 semver: 7.7.3 use-sync-external-store: 1.6.0(react@19.2.6) - ws: 8.18.3 + ws: 8.20.1 optionalDependencies: prettier: 3.8.3 transitivePeerDependencies: @@ -24200,14 +24214,14 @@ snapshots: dependencies: safe-buffer: 5.2.1 - turbo@2.8.21: + turbo@2.9.14: optionalDependencies: - '@turbo/darwin-64': 2.8.21 - '@turbo/darwin-arm64': 2.8.21 - '@turbo/linux-64': 2.8.21 - '@turbo/linux-arm64': 2.8.21 - '@turbo/windows-64': 2.8.21 - '@turbo/windows-arm64': 2.8.21 + '@turbo/darwin-64': 2.9.14 + '@turbo/darwin-arm64': 2.9.14 + '@turbo/linux-64': 2.9.14 + '@turbo/linux-arm64': 2.9.14 + '@turbo/windows-64': 2.9.14 + '@turbo/windows-arm64': 2.9.14 tween-functions@1.2.0: {} @@ -24707,7 +24721,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.21.3 + enhanced-resolve: 5.21.6 es-module-lexer: 2.1.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -24749,7 +24763,7 @@ snapshots: acorn-import-phases: 1.0.4(acorn@8.16.0) browserslist: 4.28.2 chrome-trace-event: 1.0.4 - enhanced-resolve: 5.21.3 + enhanced-resolve: 5.21.6 es-module-lexer: 2.1.0 eslint-scope: 5.1.1 events: 3.3.0 @@ -24891,6 +24905,8 @@ snapshots: ws@8.18.3: {} + ws@8.20.1: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 From fbbf1ad62fa8af830ba0829278fe42f603ae6b92 Mon Sep 17 00:00:00 2001 From: Dhruwang Jariwala <67850763+Dhruwang@users.noreply.github.com> Date: Thu, 28 May 2026 17:52:27 +0530 Subject: [PATCH 3/4] fix: clear user's pending invites before account deletion (ENG-1057) (#8149) --- apps/web/lib/user/service.test.ts | 22 ++++++++++++++++++++++ apps/web/lib/user/service.ts | 2 ++ 2 files changed, 24 insertions(+) diff --git a/apps/web/lib/user/service.test.ts b/apps/web/lib/user/service.test.ts index e604eac5c290..d93e4c04a792 100644 --- a/apps/web/lib/user/service.test.ts +++ b/apps/web/lib/user/service.test.ts @@ -18,6 +18,9 @@ vi.mock("@formbricks/database", () => ({ delete: vi.fn(), findMany: vi.fn(), }, + invite: { + deleteMany: vi.fn(), + }, }, })); @@ -192,6 +195,7 @@ describe("User Service", () => { describe("deleteUser", () => { test("should delete user and their organizations when they are single owner", async () => { vi.mocked(prisma.user.delete).mockResolvedValue(mockPrismaUser); + vi.mocked(prisma.invite.deleteMany).mockResolvedValue({ count: 0 }); vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue(mockOrganizations); vi.mocked(deleteOrganization).mockResolvedValue(); @@ -200,18 +204,36 @@ describe("User Service", () => { expect(result).toEqual(mockPrismaUser); expect(getOrganizationsWhereUserIsSingleOwner).toHaveBeenCalledWith("user1"); expect(deleteOrganization).toHaveBeenCalledWith("org1"); + expect(prisma.invite.deleteMany).toHaveBeenCalledWith({ where: { creatorId: "user1" } }); expect(prisma.user.delete).toHaveBeenCalledWith({ where: { id: "user1" }, select: publicUserSelect, }); }); + // Regression for ENG-1057: Invite.creatorId has no onDelete rule, so any + // pending invite created by the user must be cleared before user.delete + // or Postgres rejects with a foreign-key constraint violation. + test("should delete pending invites where the user is creator before deleting the user", async () => { + vi.mocked(prisma.user.delete).mockResolvedValue(mockPrismaUser); + vi.mocked(prisma.invite.deleteMany).mockResolvedValue({ count: 3 }); + vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue([]); + + await deleteUser("user1"); + + expect(prisma.invite.deleteMany).toHaveBeenCalledWith({ where: { creatorId: "user1" } }); + const inviteDeleteOrder = vi.mocked(prisma.invite.deleteMany).mock.invocationCallOrder[0]; + const userDeleteOrder = vi.mocked(prisma.user.delete).mock.invocationCallOrder[0]; + expect(inviteDeleteOrder).toBeLessThan(userDeleteOrder); + }); + test("should throw DatabaseError when prisma throws", async () => { const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", { code: "P2002", clientVersion: "5.0.0", }); vi.mocked(getOrganizationsWhereUserIsSingleOwner).mockResolvedValue([]); + vi.mocked(prisma.invite.deleteMany).mockResolvedValue({ count: 0 }); vi.mocked(prisma.user.delete).mockRejectedValue(prismaError); await expect(deleteUser("user1")).rejects.toThrow(DatabaseError); diff --git a/apps/web/lib/user/service.ts b/apps/web/lib/user/service.ts index 1bd478ec7fcc..81be96864dae 100644 --- a/apps/web/lib/user/service.ts +++ b/apps/web/lib/user/service.ts @@ -114,6 +114,8 @@ export const deleteUser = async (id: string): Promise => { await deleteOrganization(organization.id); } + await prisma.invite.deleteMany({ where: { creatorId: id } }); + const deletedUser = await deleteUserById(id); await deleteBrevoCustomerByEmail({ email: deletedUser.email }); From 5b10b275a76c2dce6d5c319625dcffd7bad22699 Mon Sep 17 00:00:00 2001 From: Tiago <1585571+xernobyl@users.noreply.github.com> Date: Thu, 28 May 2026 12:22:37 +0000 Subject: [PATCH 4/4] chore: api v3 get survey (#8043) Co-authored-by: pandeymangg --- apps/web/app/api/v3/lib/api-wrapper.test.ts | 37 +- apps/web/app/api/v3/lib/api-wrapper.ts | 53 +- apps/web/app/api/v3/lib/auth.test.ts | 26 +- apps/web/app/api/v3/lib/response.test.ts | 15 +- apps/web/app/api/v3/lib/response.ts | 17 +- .../app/api/v3/lib/workspace-context.test.ts | 29 +- apps/web/app/api/v3/lib/workspace-context.ts | 11 +- .../api/v3/surveys/[surveyId]/route.test.ts | 318 ------- .../app/api/v3/surveys/[surveyId]/route.ts | 151 ++- .../app/api/v3/surveys/authorization.test.ts | 71 ++ apps/web/app/api/v3/surveys/authorization.ts | 37 + apps/web/app/api/v3/surveys/language.test.ts | 199 ++++ apps/web/app/api/v3/surveys/language.ts | 214 +++++ apps/web/app/api/v3/surveys/route.test.ts | 372 -------- .../app/api/v3/surveys/serializers.test.ts | 585 ++++++++++++ apps/web/app/api/v3/surveys/serializers.ts | 199 +++- .../list/hooks/use-delete-survey.test.ts | 7 +- .../survey/list/lib/v3-surveys-client.test.ts | 45 +- .../survey/list/lib/v3-surveys-client.ts | 11 +- docs/api-v3-reference/openapi.yml | 897 +++++++++++++++++- docs/unify-feedback/feedback-sources.mdx | 2 +- 21 files changed, 2475 insertions(+), 821 deletions(-) delete mode 100644 apps/web/app/api/v3/surveys/[surveyId]/route.test.ts create mode 100644 apps/web/app/api/v3/surveys/authorization.test.ts create mode 100644 apps/web/app/api/v3/surveys/authorization.ts create mode 100644 apps/web/app/api/v3/surveys/language.test.ts create mode 100644 apps/web/app/api/v3/surveys/language.ts delete mode 100644 apps/web/app/api/v3/surveys/route.test.ts create mode 100644 apps/web/app/api/v3/surveys/serializers.test.ts diff --git a/apps/web/app/api/v3/lib/api-wrapper.test.ts b/apps/web/app/api/v3/lib/api-wrapper.test.ts index c86bba4006a4..5af7f762b7d4 100644 --- a/apps/web/app/api/v3/lib/api-wrapper.test.ts +++ b/apps/web/app/api/v3/lib/api-wrapper.test.ts @@ -26,6 +26,11 @@ const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({ })), })); +const { mockLoggerWarn, mockLoggerError } = vi.hoisted(() => ({ + mockLoggerWarn: vi.fn(), + mockLoggerError: vi.fn(), +})); + vi.mock("next-auth", () => ({ getServerSession: mockGetServerSession, })); @@ -53,8 +58,8 @@ vi.mock("@/app/lib/api/with-api-logging", () => ({ vi.mock("@formbricks/logger", () => ({ logger: { withContext: vi.fn(() => ({ - error: vi.fn(), - warn: vi.fn(), + error: mockLoggerError, + warn: mockLoggerWarn, })), }, })); @@ -294,6 +299,13 @@ describe("withV3ApiWrapper", () => { expect(response.status).toBe(401); expect(handler).not.toHaveBeenCalled(); expect(response.headers.get("Content-Type")).toBe("application/problem+json"); + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 401, + detail: "Not authenticated", + }), + "V3 API authentication failed" + ); }); test("returns 400 problem response for invalid query input", async () => { @@ -325,6 +337,14 @@ describe("withV3ApiWrapper", () => { const body = await response.json(); expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })])); expect(body.requestId).toBe("req-invalid"); + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 400, + detail: "Invalid query parameters", + invalidParams: expect.arrayContaining([expect.objectContaining({ name: "limit" })]), + }), + "V3 API request validation failed" + ); }); test("parses body, repeated query params, and async route params", async () => { @@ -413,6 +433,19 @@ describe("withV3ApiWrapper", () => { reason: "Malformed JSON input, please check your request body", }, ]); + expect(mockLoggerWarn).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: 400, + detail: "Invalid request body", + invalidParams: [ + { + name: "body", + reason: "Malformed JSON input, please check your request body", + }, + ], + }), + "V3 API request validation failed" + ); }); test("returns 413 problem response for oversized JSON input", async () => { diff --git a/apps/web/app/api/v3/lib/api-wrapper.ts b/apps/web/app/api/v3/lib/api-wrapper.ts index 60eb18479ddf..6700ddee729c 100644 --- a/apps/web/app/api/v3/lib/api-wrapper.ts +++ b/apps/web/app/api/v3/lib/api-wrapper.ts @@ -79,6 +79,13 @@ function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "pa })); } +type TV3InputParseFailure = { + ok: false; + response: Response; + detail: string; + invalidParams: InvalidParam[]; +}; + function searchParamsToObject(searchParams: URLSearchParams): Record { const query: Record = {}; @@ -159,13 +166,7 @@ async function parseV3Input( schemas: S | undefined, requestId: string, instance: string -): Promise< - | { ok: true; parsedInput: TV3ParsedInput } - | { - ok: false; - response: Response; - } -> { +): Promise<{ ok: true; parsedInput: TV3ParsedInput } | TV3InputParseFailure> { const parsedInput = {} as TV3ParsedInput; if (schemas?.body) { @@ -177,26 +178,36 @@ async function parseV3Input( if (error instanceof RequestBodyTooLargeError) { return { ok: false, + detail: error.message, + invalidParams: [], response: problemPayloadTooLarge(requestId, error.message, instance), }; } + const invalidParams = [ + { name: "body", reason: "Malformed JSON input, please check your request body" }, + ]; return { ok: false, + detail: "Invalid request body", + invalidParams, response: problemBadRequest(requestId, "Invalid request body", { instance, - invalid_params: [{ name: "body", reason: "Malformed JSON input, please check your request body" }], + invalid_params: invalidParams, }), }; } const bodyResult = schemas.body.safeParse(bodyData); if (!bodyResult.success) { + const invalidParams = formatZodIssues(bodyResult.error, "body"); return { ok: false, + detail: "Invalid request body", + invalidParams, response: problemBadRequest(requestId, "Invalid request body", { instance, - invalid_params: formatZodIssues(bodyResult.error, "body"), + invalid_params: invalidParams, }), }; } @@ -207,11 +218,14 @@ async function parseV3Input( if (schemas?.query) { const queryResult = schemas.query.safeParse(searchParamsToObject(req.nextUrl.searchParams)); if (!queryResult.success) { + const invalidParams = formatZodIssues(queryResult.error, "query"); return { ok: false, + detail: "Invalid query parameters", + invalidParams, response: problemBadRequest(requestId, "Invalid query parameters", { instance, - invalid_params: formatZodIssues(queryResult.error, "query"), + invalid_params: invalidParams, }), }; } @@ -222,11 +236,14 @@ async function parseV3Input( if (schemas?.params) { const paramsResult = schemas.params.safeParse(await getRouteParams(props)); if (!paramsResult.success) { + const invalidParams = formatZodIssues(paramsResult.error, "params"); return { ok: false, + detail: "Invalid route parameters", + invalidParams, response: problemBadRequest(requestId, "Invalid route parameters", { instance, - invalid_params: formatZodIssues(paramsResult.error, "params"), + invalid_params: invalidParams, }), }; } @@ -375,13 +392,23 @@ export const withV3ApiWrapper = ({ @@ -19,8 +19,8 @@ vi.mock("@/lib/utils/helper", () => ({ getOrganizationIdFromWorkspaceId: vi.fn(), })); -vi.mock("@/lib/utils/resolve-client-id", () => ({ - findWorkspaceByIdOrLegacyEnvId: vi.fn(), +vi.mock("@/lib/workspace/service", () => ({ + getWorkspace: vi.fn(), })); vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({ @@ -39,7 +39,7 @@ describe("requireSessionWorkspaceAccess", () => { expect(body.requestId).toBe(requestId); expect(body.status).toBe(401); expect(body.code).toBe("not_authenticated"); - expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled(); + expect(getWorkspace).not.toHaveBeenCalled(); expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); }); @@ -55,11 +55,11 @@ describe("requireSessionWorkspaceAccess", () => { const body = await (result as Response).json(); expect(body.requestId).toBe(requestId); expect(body.code).toBe("not_authenticated"); - expect(findWorkspaceByIdOrLegacyEnvId).not.toHaveBeenCalled(); + expect(getWorkspace).not.toHaveBeenCalled(); }); test("returns 403 when workspace is not found (avoid leaking existence)", async () => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null); + vi.mocked(getWorkspace).mockResolvedValueOnce(null); const result = await requireSessionWorkspaceAccess( { user: { id: "user_1" }, expires: "" } as any, "ws_nonexistent", @@ -72,12 +72,12 @@ describe("requireSessionWorkspaceAccess", () => { const body = await (result as Response).json(); expect(body.requestId).toBe(requestId); expect(body.code).toBe("forbidden"); - expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent"); + expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent"); expect(checkAuthorizationUpdated).not.toHaveBeenCalled(); }); test("returns 403 when user has no access to workspace", async () => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" }); + vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1"); vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Not authorized")); const result = await requireSessionWorkspaceAccess( @@ -102,7 +102,7 @@ describe("requireSessionWorkspaceAccess", () => { }); test("returns workspace context when session is valid and user has access", async () => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_abc" }); + vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_abc" } as any); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_1"); vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any); const result = await requireSessionWorkspaceAccess( @@ -144,7 +144,7 @@ function wsPerm(workspaceId: string, permission: ApiKeyPermission = ApiKeyPermis describe("requireV3WorkspaceAccess", () => { beforeEach(() => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValue({ id: "proj_k" }); + vi.mocked(getWorkspace).mockResolvedValue({ id: "proj_k" } as any); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValue("org_k"); }); @@ -154,7 +154,7 @@ describe("requireV3WorkspaceAccess", () => { }); test("delegates to session flow when user is present", async () => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "proj_s" }); + vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "proj_s" } as any); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_s"); vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any); const r = await requireV3WorkspaceAccess( @@ -179,7 +179,7 @@ describe("requireV3WorkspaceAccess", () => { workspaceId: "proj_k", organizationId: "org_k", }); - expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("proj_k"); + expect(getWorkspace).toHaveBeenCalledWith("proj_k"); }); test("returns context for API key with write on workspace", async () => { @@ -239,7 +239,7 @@ describe("requireV3WorkspaceAccess", () => { }); test("returns 403 when the workspace cannot be resolved for an API key", async () => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null); + vi.mocked(getWorkspace).mockResolvedValueOnce(null); const auth = { ...keyBase, workspacePermissions: [wsPerm("proj_k", ApiKeyPermission.manage)], diff --git a/apps/web/app/api/v3/lib/response.test.ts b/apps/web/app/api/v3/lib/response.test.ts index a7642a1263e3..b3fd36ee194e 100644 --- a/apps/web/app/api/v3/lib/response.test.ts +++ b/apps/web/app/api/v3/lib/response.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "vitest"; import { + noContentResponse, problemBadRequest, problemForbidden, problemInternalError, @@ -13,7 +14,7 @@ import { describe("v3 problem responses", () => { test("problemBadRequest includes invalid_params", async () => { const res = problemBadRequest("rid", "bad", { - invalid_params: [{ name: "x", reason: "y" }], + invalid_params: [{ name: "x", reason: "y", identifier: "canonical-x" }], instance: "/p", }); expect(res.status).toBe(400); @@ -21,7 +22,7 @@ describe("v3 problem responses", () => { const body = await res.json(); expect(body.code).toBe("bad_request"); expect(body.requestId).toBe("rid"); - expect(body.invalid_params).toEqual([{ name: "x", reason: "y" }]); + expect(body.invalid_params).toEqual([{ name: "x", reason: "y", identifier: "canonical-x" }]); expect(body.instance).toBe("/p"); }); @@ -118,3 +119,13 @@ describe("successResponse", () => { expect(res.headers.get("Cache-Control")).toBe("private, max-age=60"); }); }); + +describe("noContentResponse", () => { + test("returns 204 without a body", async () => { + const res = noContentResponse({ requestId: "req-empty" }); + expect(res.status).toBe(204); + expect(res.headers.get("X-Request-Id")).toBe("req-empty"); + expect(res.headers.get("Cache-Control")).toContain("no-store"); + expect(await res.text()).toBe(""); + }); +}); diff --git a/apps/web/app/api/v3/lib/response.ts b/apps/web/app/api/v3/lib/response.ts index 40e862818ac0..83b21b15378f 100644 --- a/apps/web/app/api/v3/lib/response.ts +++ b/apps/web/app/api/v3/lib/response.ts @@ -6,7 +6,7 @@ const PROBLEM_JSON = "application/problem+json" as const; const CACHE_NO_STORE = "private, no-store" as const; -export type InvalidParam = { name: string; reason: string }; +export type InvalidParam = { name: string; reason: string; identifier?: string }; export type ProblemExtension = { code?: string; @@ -182,3 +182,18 @@ export function successResponse( } ); } + +export function noContentResponse(options?: { requestId?: string; cache?: string }): Response { + const headers: Record = { + "Cache-Control": options?.cache ?? CACHE_NO_STORE, + }; + + if (options?.requestId) { + headers["X-Request-Id"] = options.requestId; + } + + return new Response(null, { + status: 204, + headers, + }); +} diff --git a/apps/web/app/api/v3/lib/workspace-context.test.ts b/apps/web/app/api/v3/lib/workspace-context.test.ts index e80ca1c45b0c..efee0340bb80 100644 --- a/apps/web/app/api/v3/lib/workspace-context.test.ts +++ b/apps/web/app/api/v3/lib/workspace-context.test.ts @@ -1,45 +1,34 @@ import { describe, expect, test, vi } from "vitest"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; -import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id"; +import { getWorkspace } from "@/lib/workspace/service"; import { resolveV3WorkspaceContext } from "./workspace-context"; -vi.mock("@/lib/utils/helper", () => ({ - getOrganizationIdFromWorkspaceId: vi.fn(), +vi.mock("@/lib/workspace/service", () => ({ + getWorkspace: vi.fn(), })); -vi.mock("@/lib/utils/resolve-client-id", () => ({ - findWorkspaceByIdOrLegacyEnvId: vi.fn(), +vi.mock("@/lib/utils/helper", () => ({ + getOrganizationIdFromWorkspaceId: vi.fn(), })); describe("resolveV3WorkspaceContext", () => { test("returns workspaceId and organizationId when workspace exists", async () => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_abc" }); + vi.mocked(getWorkspace).mockResolvedValueOnce({ id: "ws_abc" }); vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_123"); const result = await resolveV3WorkspaceContext("ws_abc"); expect(result).toEqual({ workspaceId: "ws_abc", organizationId: "org_123", }); - expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_abc"); + expect(getWorkspace).toHaveBeenCalledWith("ws_abc"); expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_abc"); }); - test("resolves legacy environmentId to canonical workspaceId", async () => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce({ id: "ws_canonical" }); - vi.mocked(getOrganizationIdFromWorkspaceId).mockResolvedValueOnce("org_456"); - const result = await resolveV3WorkspaceContext("env_legacy"); - expect(result).toEqual({ - workspaceId: "ws_canonical", - organizationId: "org_456", - }); - expect(getOrganizationIdFromWorkspaceId).toHaveBeenCalledWith("ws_canonical"); - }); - test("throws when workspace does not exist", async () => { - vi.mocked(findWorkspaceByIdOrLegacyEnvId).mockResolvedValueOnce(null); + vi.mocked(getWorkspace).mockResolvedValueOnce(null); await expect(resolveV3WorkspaceContext("ws_nonexistent")).rejects.toThrow(ResourceNotFoundError); - expect(findWorkspaceByIdOrLegacyEnvId).toHaveBeenCalledWith("ws_nonexistent"); + expect(getWorkspace).toHaveBeenCalledWith("ws_nonexistent"); expect(getOrganizationIdFromWorkspaceId).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/app/api/v3/lib/workspace-context.ts b/apps/web/app/api/v3/lib/workspace-context.ts index d37415055203..62819655f315 100644 --- a/apps/web/app/api/v3/lib/workspace-context.ts +++ b/apps/web/app/api/v3/lib/workspace-context.ts @@ -6,7 +6,7 @@ */ import { ResourceNotFoundError } from "@formbricks/types/errors"; import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper"; -import { findWorkspaceByIdOrLegacyEnvId } from "@/lib/utils/resolve-client-id"; +import { getWorkspace } from "@/lib/workspace/service"; /** * Internal IDs derived from a V3 workspace identifier. @@ -19,21 +19,20 @@ export type V3WorkspaceContext = { }; /** - * Resolves a V3 API workspaceId (or legacy environmentId) to internal workspaceId and organizationId. + * Resolves a V3 API workspaceId to internal workspaceId and organizationId. * * @throws ResourceNotFoundError if the workspace does not exist. */ export async function resolveV3WorkspaceContext(workspaceId: string): Promise { - const workspace = await findWorkspaceByIdOrLegacyEnvId(workspaceId); + const workspace = await getWorkspace(workspaceId); if (!workspace) { throw new ResourceNotFoundError("workspace", workspaceId); } - const canonicalId = workspace.id; - const organizationId = await getOrganizationIdFromWorkspaceId(canonicalId); + const organizationId = await getOrganizationIdFromWorkspaceId(workspace.id); return { - workspaceId: canonicalId, + workspaceId: workspace.id, organizationId, }; } diff --git a/apps/web/app/api/v3/surveys/[surveyId]/route.test.ts b/apps/web/app/api/v3/surveys/[surveyId]/route.test.ts deleted file mode 100644 index c383c24b8b69..000000000000 --- a/apps/web/app/api/v3/surveys/[surveyId]/route.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -import { ApiKeyPermission } from "@prisma/client"; -import { NextRequest } from "next/server"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; -import { getSurvey } from "@/lib/survey/service"; -import { deleteSurvey } from "@/modules/survey/lib/surveys"; -import { DELETE } from "./route"; - -const { mockAuthenticateRequest } = vi.hoisted(() => ({ - mockAuthenticateRequest: vi.fn(), -})); - -const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({ - mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined), - mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({ - action, - targetType, - userId: "unknown", - targetId: "unknown", - organizationId: "unknown", - status: "failure", - oldObject: undefined, - newObject: undefined, - userType: "api", - apiUrl, - })), -})); - -vi.mock("next-auth", () => ({ - getServerSession: vi.fn(), -})); - -vi.mock("@/app/api/v1/auth", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, authenticateRequest: mockAuthenticateRequest }; -}); - -vi.mock("@/modules/core/rate-limit/helpers", () => ({ - applyRateLimit: vi.fn().mockResolvedValue(undefined), - applyIPRateLimit: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/constants", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, AUDIT_LOG_ENABLED: false }; -}); - -vi.mock("@/app/api/v3/lib/auth", () => ({ - requireV3WorkspaceAccess: vi.fn(), -})); - -vi.mock("@/lib/survey/service", () => ({ - getSurvey: vi.fn(), -})); - -vi.mock("@/modules/survey/lib/surveys", () => ({ - deleteSurvey: vi.fn(), -})); - -vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ - queueAuditEvent: mockQueueAuditEvent, -})); - -vi.mock("@/app/lib/api/with-api-logging", () => ({ - buildAuditLogBaseObject: mockBuildAuditLogBaseObject, -})); - -vi.mock("@formbricks/logger", () => ({ - logger: { - withContext: vi.fn(() => ({ - warn: vi.fn(), - error: vi.fn(), - })), - }, -})); - -const getServerSession = vi.mocked((await import("next-auth")).getServerSession); -const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent); - -const surveyId = "clxx1234567890123456789012"; -const workspaceId = "clzz9876543210987654321098"; - -function createRequest(url: string, requestId?: string, extraHeaders?: Record): NextRequest { - const headers: Record = { ...extraHeaders }; - if (requestId) { - headers["x-request-id"] = requestId; - } - - return new NextRequest(url, { - method: "DELETE", - headers, - }); -} - -const apiKeyAuth = { - type: "apiKey" as const, - apiKeyId: "key_1", - organizationId: "org_1", - organizationAccess: { - accessControl: { read: true, write: true }, - }, - workspacePermissions: [ - { - workspaceId, - workspaceName: "W", - permission: ApiKeyPermission.write, - }, - ], -}; - -describe("DELETE /api/v3/surveys/[surveyId]", () => { - beforeEach(() => { - vi.resetAllMocks(); - getServerSession.mockResolvedValue({ - user: { id: "user_1", name: "User", email: "u@example.com" }, - expires: "2026-01-01", - } as any); - mockAuthenticateRequest.mockResolvedValue(null); - vi.mocked(getSurvey).mockResolvedValue({ - id: surveyId, - name: "Delete me", - workspaceId: workspaceId, - type: "link", - status: "draft", - createdAt: new Date("2026-04-15T10:00:00.000Z"), - updatedAt: new Date("2026-04-15T10:00:00.000Z"), - responseCount: 0, - creator: { name: "User" }, - singleUse: null, - } as any); - vi.mocked(deleteSurvey).mockResolvedValue({ - id: surveyId, - workspaceId, - type: "link", - segment: null, - triggers: [], - } as any); - vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({ - workspaceId, - organizationId: "org_1", - }); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - test("returns 401 when no session and no API key", async () => { - getServerSession.mockResolvedValue(null); - mockAuthenticateRequest.mockResolvedValue(null); - - const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), { - params: Promise.resolve({ surveyId }), - } as never); - - expect(res.status).toBe(401); - expect(vi.mocked(getSurvey)).not.toHaveBeenCalled(); - }); - - test("returns 200 with session auth and deletes the survey", async () => { - const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), { - params: Promise.resolve({ surveyId }), - } as never); - - expect(res.status).toBe(200); - expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.any(Object) }), - workspaceId, - "readWrite", - "req-delete", - `/api/v3/surveys/${surveyId}` - ); - expect(deleteSurvey).toHaveBeenCalledWith(surveyId); - expect(await res.json()).toEqual({ - data: { - id: surveyId, - }, - }); - }); - - test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => { - getServerSession.mockResolvedValue(null); - mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any); - - const res = await DELETE( - createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", { - "x-api-key": "fbk_test", - }), - { - params: Promise.resolve({ surveyId }), - } as never - ); - - expect(res.status).toBe(200); - expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( - expect.objectContaining({ apiKeyId: "key_1" }), - workspaceId, - "readWrite", - "req-api-key", - `/api/v3/surveys/${surveyId}` - ); - }); - - test("returns 400 when surveyId is invalid", async () => { - const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), { - params: Promise.resolve({ surveyId: "not-a-cuid" }), - } as never); - - expect(res.status).toBe(400); - expect(vi.mocked(getSurvey)).not.toHaveBeenCalled(); - }); - - test("returns 403 when the survey does not exist", async () => { - vi.mocked(getSurvey).mockResolvedValueOnce(null); - - const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), { - params: Promise.resolve({ surveyId }), - } as never); - - expect(res.status).toBe(403); - expect(deleteSurvey).not.toHaveBeenCalled(); - }); - - test("returns 403 when the user lacks readWrite workspace access", async () => { - vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce( - new Response( - JSON.stringify({ - title: "Forbidden", - status: 403, - detail: "You are not authorized to access this resource", - requestId: "req-forbidden", - }), - { status: 403, headers: { "Content-Type": "application/problem+json" } } - ) - ); - - const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), { - params: Promise.resolve({ surveyId }), - } as never); - - expect(res.status).toBe(403); - expect(deleteSurvey).not.toHaveBeenCalled(); - expect(queueAuditEvent).toHaveBeenCalledWith( - expect.objectContaining({ - action: "deleted", - targetType: "survey", - targetId: "unknown", - organizationId: "unknown", - userId: "user_1", - userType: "user", - status: "failure", - oldObject: undefined, - }) - ); - }); - - test("returns 500 when survey deletion fails", async () => { - vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down")); - - const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), { - params: Promise.resolve({ surveyId }), - } as never); - - expect(res.status).toBe(500); - const body = await res.json(); - expect(body.code).toBe("internal_server_error"); - }); - - test("returns 403 when the survey is deleted after authorization succeeds", async () => { - vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId)); - - const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), { - params: Promise.resolve({ surveyId }), - } as never); - - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.code).toBe("forbidden"); - expect(queueAuditEvent).toHaveBeenCalledWith( - expect.objectContaining({ - action: "deleted", - targetType: "survey", - targetId: surveyId, - organizationId: "org_1", - userId: "user_1", - userType: "user", - status: "failure", - oldObject: expect.objectContaining({ - id: surveyId, - workspaceId: workspaceId, - }), - }) - ); - }); - - test("queues an audit log with target, actor, organization, and old object", async () => { - await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), { - params: Promise.resolve({ surveyId }), - } as never); - - expect(queueAuditEvent).toHaveBeenCalledWith( - expect.objectContaining({ - action: "deleted", - targetType: "survey", - targetId: surveyId, - organizationId: "org_1", - userId: "user_1", - userType: "user", - status: "success", - oldObject: expect.objectContaining({ - id: surveyId, - workspaceId: workspaceId, - }), - }) - ); - }); -}); diff --git a/apps/web/app/api/v3/surveys/[surveyId]/route.ts b/apps/web/app/api/v3/surveys/[surveyId]/route.ts index 87d10273eace..774fba1f900c 100644 --- a/apps/web/app/api/v3/surveys/[surveyId]/route.ts +++ b/apps/web/app/api/v3/surveys/[surveyId]/route.ts @@ -2,42 +2,144 @@ import { z } from "zod"; import { logger } from "@formbricks/logger"; import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper"; -import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; -import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response"; -import { getSurvey } from "@/lib/survey/service"; +import { + noContentResponse, + problemBadRequest, + problemForbidden, + problemInternalError, + successResponse, +} from "@/app/api/v3/lib/response"; +import { + V3SurveyLanguageError, + V3SurveyUnsupportedShapeError, + serializeV3SurveyResource, +} from "@/app/api/v3/surveys/serializers"; import { deleteSurvey } from "@/modules/survey/lib/surveys"; +import { getAuthorizedV3Survey } from "../authorization"; +import { parseV3SurveyLanguageQuery } from "../language"; + +const surveyParamsSchema = z.object({ + surveyId: z.cuid2(), +}); + +const surveyQuerySchema = z + .object({ + lang: z + .union([z.string(), z.array(z.string())]) + .transform((value, ctx) => { + const parsedLanguageQuery = parseV3SurveyLanguageQuery(value); + + if (!parsedLanguageQuery.ok) { + ctx.addIssue({ + code: "custom", + message: parsedLanguageQuery.message, + }); + return z.NEVER; + } + + return parsedLanguageQuery.languages; + }) + .optional(), + }) + .strict(); + +export const GET = withV3ApiWrapper({ + auth: "both", + schemas: { + params: surveyParamsSchema, + query: surveyQuerySchema, + }, + handler: async ({ parsedInput, authentication, requestId, instance }) => { + const surveyId = parsedInput.params.surveyId; + const log = logger.withContext({ requestId, surveyId }); + + try { + const { survey, response } = await getAuthorizedV3Survey({ + surveyId, + authentication, + access: "read", + requestId, + instance, + }); + + if (response) { + log.warn({ statusCode: response.status }, "Survey not found or not accessible"); + return response; + } + + try { + return successResponse(serializeV3SurveyResource(survey, { lang: parsedInput.query.lang }), { + requestId, + cache: "private, no-store", + }); + } catch (error) { + if (error instanceof V3SurveyLanguageError) { + log.warn( + { statusCode: 400, detail: error.message, lang: parsedInput.query.lang }, + "Invalid survey language selector" + ); + return problemBadRequest(requestId, error.message, { + instance, + invalid_params: [ + { + name: "lang", + reason: error.message, + ...(error.normalizedCode && { identifier: error.normalizedCode }), + }, + ], + }); + } + + if (error instanceof V3SurveyUnsupportedShapeError) { + log.warn({ statusCode: 400, detail: error.message }, "Unsupported v3 survey shape"); + return problemBadRequest(requestId, error.message, { + instance, + invalid_params: [ + { + name: "survey", + reason: error.message, + }, + ], + }); + } + + throw error; + } + } catch (error) { + if (error instanceof DatabaseError) { + log.error({ error, statusCode: 500 }, "Database error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + + log.error({ error, statusCode: 500 }, "V3 survey get unexpected error"); + return problemInternalError(requestId, "An unexpected error occurred.", instance); + } + }, +}); export const DELETE = withV3ApiWrapper({ auth: "both", action: "deleted", targetType: "survey", schemas: { - params: z.object({ - surveyId: z.cuid2(), - }), + params: surveyParamsSchema, }, handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => { const surveyId = parsedInput.params.surveyId; const log = logger.withContext({ requestId, surveyId }); try { - const survey = await getSurvey(surveyId); - - if (!survey) { - log.warn({ statusCode: 403 }, "Survey not found or not accessible"); - return problemForbidden(requestId, "You are not authorized to access this resource", instance); - } - - const authResult = await requireV3WorkspaceAccess( + const { survey, authResult, response } = await getAuthorizedV3Survey({ + surveyId, authentication, - survey.workspaceId, - "readWrite", + access: "readWrite", requestId, - instance - ); + instance, + }); - if (authResult instanceof Response) { - return authResult; + if (response) { + log.warn({ statusCode: 403 }, "Survey not found or not accessible"); + return response; } if (auditLog) { @@ -46,14 +148,9 @@ export const DELETE = withV3ApiWrapper({ auditLog.oldObject = survey; } - const deletedSurvey = await deleteSurvey(surveyId); + await deleteSurvey(surveyId); - return successResponse( - { - id: deletedSurvey.id, - }, - { requestId } - ); + return noContentResponse({ requestId }); } catch (error) { if (error instanceof ResourceNotFoundError) { log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible"); diff --git a/apps/web/app/api/v3/surveys/authorization.test.ts b/apps/web/app/api/v3/surveys/authorization.test.ts new file mode 100644 index 000000000000..639c03107a9f --- /dev/null +++ b/apps/web/app/api/v3/surveys/authorization.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test, vi } from "vitest"; +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { getSurvey } from "@/lib/survey/service"; +import { getAuthorizedV3Survey } from "./authorization"; + +vi.mock("@/app/api/v3/lib/auth", () => ({ + requireV3WorkspaceAccess: vi.fn(), +})); + +vi.mock("@/lib/survey/service", () => ({ + getSurvey: vi.fn(), +})); + +const survey = { + id: "clsv1234567890123456789012", + workspaceId: "clxx1234567890123456789012", +}; +const surveyRecord = survey as unknown as NonNullable>>; + +describe("getAuthorizedV3Survey", () => { + test("returns a generic forbidden response when the survey does not exist", async () => { + vi.mocked(getSurvey).mockResolvedValue(null); + + const result = await getAuthorizedV3Survey({ + surveyId: survey.id, + authentication: null, + access: "read", + requestId: "req_1", + instance: "/api/v3/surveys/clsv1234567890123456789012", + }); + + expect(result.response?.status).toBe(403); + expect(requireV3WorkspaceAccess).not.toHaveBeenCalled(); + }); + + test("returns the authorization response when workspace access is denied", async () => { + const forbiddenResponse = new Response(null, { status: 403 }); + vi.mocked(getSurvey).mockResolvedValue(surveyRecord); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(forbiddenResponse); + + const result = await getAuthorizedV3Survey({ + surveyId: survey.id, + authentication: null, + access: "readWrite", + requestId: "req_2", + instance: "/api/v3/surveys/clsv1234567890123456789012", + }); + + expect(result.response).toBe(forbiddenResponse); + }); + + test("returns the survey and authorization context when access is allowed", async () => { + const authResult = { workspaceId: survey.workspaceId, organizationId: "org_1" }; + vi.mocked(getSurvey).mockResolvedValue(surveyRecord); + vi.mocked(requireV3WorkspaceAccess).mockResolvedValue(authResult); + + const result = await getAuthorizedV3Survey({ + surveyId: survey.id, + authentication: null, + access: "read", + requestId: "req_3", + instance: "/api/v3/surveys/clsv1234567890123456789012", + }); + + expect(result).toEqual({ + survey, + authResult, + response: null, + }); + }); +}); diff --git a/apps/web/app/api/v3/surveys/authorization.ts b/apps/web/app/api/v3/surveys/authorization.ts new file mode 100644 index 000000000000..ed4623e98569 --- /dev/null +++ b/apps/web/app/api/v3/surveys/authorization.ts @@ -0,0 +1,37 @@ +import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; +import { problemForbidden } from "@/app/api/v3/lib/response"; +import type { TV3Authentication } from "@/app/api/v3/lib/types"; +import { getSurvey } from "@/lib/survey/service"; + +export async function getAuthorizedV3Survey(params: { + surveyId: string; + authentication: TV3Authentication; + access: "read" | "readWrite"; + requestId: string; + instance: string; +}) { + const { surveyId, authentication, access, requestId, instance } = params; + const survey = await getSurvey(surveyId); + + if (!survey) { + return { + survey: null, + authResult: null, + response: problemForbidden(requestId, "You are not authorized to access this resource", instance), + }; + } + + const authResult = await requireV3WorkspaceAccess( + authentication, + survey.workspaceId, + access, + requestId, + instance + ); + + if (authResult instanceof Response) { + return { survey: null, authResult: null, response: authResult }; + } + + return { survey, authResult, response: null }; +} diff --git a/apps/web/app/api/v3/surveys/language.test.ts b/apps/web/app/api/v3/surveys/language.test.ts new file mode 100644 index 000000000000..76fbbc75486b --- /dev/null +++ b/apps/web/app/api/v3/surveys/language.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, test } from "vitest"; +import { + normalizeV3SurveyLanguageIdentifier, + normalizeV3SurveyLanguageTag, + parseV3SurveyLanguageQuery, + resolveV3SurveyLanguageCode, +} from "./language"; + +const languages = [ + { code: "en-US", enabled: true, alias: "english" }, + { code: "de-DE", enabled: true, alias: "de" }, + { code: "fr-FR", enabled: false }, +]; + +describe("normalizeV3SurveyLanguageTag", () => { + test.each([ + ["EN_us", "en-US"], + ["en-us", "en-US"], + ["zh_hans", "zh-Hans"], + ["zh_hans_cn", "zh-Hans-CN"], + ["ZH-hant-tw", "zh-Hant-TW"], + ])("normalizes %s to %s", (input, expected) => { + expect(normalizeV3SurveyLanguageTag(input)).toBe(expected); + }); + + test("returns null for invalid language tags", () => { + expect(normalizeV3SurveyLanguageTag("not a locale")).toBeNull(); + }); + + test("returns null for language-only tags", () => { + expect(normalizeV3SurveyLanguageTag("de")).toBeNull(); + }); +}); + +describe("normalizeV3SurveyLanguageIdentifier", () => { + test.each([ + ["hi", "hi-IN"], + ["HI", "hi-IN"], + ["de", "de-DE"], + ["hi_in", "hi-IN"], + ])("normalizes legacy identifier %s to %s", (input, expected) => { + expect(normalizeV3SurveyLanguageIdentifier(input)).toBe(expected); + }); + + test("does not guess ambiguous legacy identifiers", () => { + expect(normalizeV3SurveyLanguageIdentifier("pt")).toBeNull(); + }); +}); + +describe("parseV3SurveyLanguageQuery", () => { + test("parses comma-separated language selectors", () => { + expect(parseV3SurveyLanguageQuery("de-DE, pt_PT, EN_us, zh_hans_cn, hi")).toEqual({ + ok: true, + languages: ["de-DE", "pt_PT", "EN_us", "zh_hans_cn", "hi"], + }); + }); + + test("parses repeated language selectors", () => { + expect(parseV3SurveyLanguageQuery(["de-DE", "pt_PT,en_us"])).toEqual({ + ok: true, + languages: ["de-DE", "pt_PT", "en_us"], + }); + }); + + test("deduplicates equivalent canonical selectors case-insensitively", () => { + expect(parseV3SurveyLanguageQuery("de-DE,DE_de,hi-IN,HI_in")).toEqual({ + ok: true, + languages: ["de-DE", "hi-IN"], + }); + }); + + test("keeps language-only and qualified locale selectors as distinct entries", () => { + expect(parseV3SurveyLanguageQuery("hi,hi-IN")).toEqual({ + ok: true, + languages: ["hi", "hi-IN"], + }); + }); + + test("rejects empty language selectors", () => { + expect(parseV3SurveyLanguageQuery("de-DE,")).toEqual({ + ok: false, + message: "Language selector must contain valid comma-separated language selectors", + }); + }); + + test("keeps invalid-looking selectors for survey-aware alias resolution", () => { + expect(parseV3SurveyLanguageQuery("not a locale")).toEqual({ + ok: true, + languages: ["not a locale"], + }); + }); + + test("keeps language-only selectors for survey-aware compatibility resolution", () => { + expect(parseV3SurveyLanguageQuery("de")).toEqual({ + ok: true, + languages: ["de"], + }); + }); +}); + +describe("resolveV3SurveyLanguageCode", () => { + test("matches configured languages case-insensitively and normalizes underscores", () => { + expect(resolveV3SurveyLanguageCode("DE_de", languages)).toEqual({ ok: true, code: "de-DE" }); + }); + + test("matches configured script-region languages case-insensitively and normalizes underscores", () => { + expect(resolveV3SurveyLanguageCode("ZH_hans_cn", [{ code: "zh-Hans-CN", enabled: true }])).toEqual({ + ok: true, + code: "zh-Hans-CN", + }); + }); + + test("matches configured script-only languages case-insensitively and normalizes underscores", () => { + expect(resolveV3SurveyLanguageCode("ZH_hans", [{ code: "zh-Hans", enabled: true }])).toEqual({ + ok: true, + code: "zh-Hans", + }); + }); + + test("resolves disabled configured languages for management reads", () => { + expect(resolveV3SurveyLanguageCode("fr-FR", languages)).toEqual({ ok: true, code: "fr-FR" }); + }); + + test("resolves legacy stored language codes and canonical selectors", () => { + const legacyLanguages = [{ code: "hi", enabled: true, alias: null }]; + + expect(resolveV3SurveyLanguageCode("hi", legacyLanguages)).toEqual({ ok: true, code: "hi-IN" }); + expect(resolveV3SurveyLanguageCode("hi-IN", legacyLanguages)).toEqual({ ok: true, code: "hi-IN" }); + expect(resolveV3SurveyLanguageCode("HI_in", legacyLanguages)).toEqual({ ok: true, code: "hi-IN" }); + }); + + test("resolves configured language aliases", () => { + expect(resolveV3SurveyLanguageCode("english", languages)).toEqual({ ok: true, code: "en-US" }); + expect(resolveV3SurveyLanguageCode("de", languages)).toEqual({ ok: true, code: "de-DE" }); + }); + + test("rejects ambiguous language-only selectors", () => { + expect( + resolveV3SurveyLanguageCode("en", [ + { code: "en-US", enabled: true }, + { code: "en-GB", enabled: true }, + ]) + ).toEqual({ + ok: false, + reason: "ambiguous", + normalizedCode: "en", + message: "Language 'en' is ambiguous for this survey. Matching languages: en-US, en-GB", + }); + }); + + test("reports the user's input rather than a guessed locale when unknown", () => { + expect(resolveV3SurveyLanguageCode("en", [{ code: "de-DE", enabled: true }])).toEqual({ + ok: false, + reason: "unknown", + normalizedCode: "en", + message: "Language 'en' is not configured for this survey", + }); + }); + + test("resolves language-only selectors to the only matching configured locale", () => { + expect(resolveV3SurveyLanguageCode("pt", [{ code: "pt-BR", enabled: true }])).toEqual({ + ok: true, + code: "pt-BR", + }); + }); + + test("does not fallback full locale selectors to a different configured region", () => { + expect(resolveV3SurveyLanguageCode("en-GB", [{ code: "en-US", enabled: true }])).toEqual({ + ok: false, + reason: "unknown", + normalizedCode: "en-GB", + message: "Language 'en-GB' is not configured for this survey", + }); + }); + + test("returns unknown for languages not configured on the survey", () => { + expect(resolveV3SurveyLanguageCode("ZH_hant_tw", languages)).toEqual({ + ok: false, + reason: "unknown", + normalizedCode: "zh-Hant-TW", + message: "Language 'zh-Hant-TW' is not configured for this survey", + }); + }); + + test("rejects selectors that are neither locale codes nor configured aliases", () => { + expect(resolveV3SurveyLanguageCode("not a locale", languages)).toEqual({ + ok: false, + reason: "invalid", + message: "Language 'not a locale' is not a valid locale code or configured language alias", + }); + }); + + test("resolves the implicit default locale for surveys without configured languages", () => { + expect(resolveV3SurveyLanguageCode("en-US", [{ code: "en-US", enabled: true }])).toEqual({ + ok: true, + code: "en-US", + }); + }); +}); diff --git a/apps/web/app/api/v3/surveys/language.ts b/apps/web/app/api/v3/surveys/language.ts new file mode 100644 index 000000000000..ebbba0bba344 --- /dev/null +++ b/apps/web/app/api/v3/surveys/language.ts @@ -0,0 +1,214 @@ +type TV3SurveyLanguageInput = { + code: string; + enabled: boolean; + alias?: string | null; +}; + +type TV3SurveyLanguageQueryInput = string | string[]; + +type TResolveV3SurveyLanguageCodeResult = + | { ok: true; code: string } + | { ok: false; reason: "ambiguous" | "invalid" | "unknown"; message: string; normalizedCode?: string }; + +type TParseV3SurveyLanguageQueryResult = { ok: true; languages: string[] } | { ok: false; message: string }; + +const V3_SURVEY_LANGUAGE_TAG_REGEX = /^[a-z]{2}(?:-[A-Z]{2}|-[A-Z][a-z]{3}(?:-[A-Z]{2})?)$/; +const V3_LEGACY_LANGUAGE_CODE_MAP: Record = { + ar: "ar-SA", + cs: "cs-CZ", + da: "da-DK", + de: "de-DE", + en: "en-US", + es: "es-ES", + fi: "fi-FI", + fr: "fr-FR", + he: "he-IL", + hi: "hi-IN", + hu: "hu-HU", + it: "it-IT", + ja: "ja-JP", + ko: "ko-KR", + nb: "nb-NO", + nl: "nl-NL", + no: "nb-NO", + pl: "pl-PL", + ro: "ro-RO", + ru: "ru-RU", + sv: "sv-SE", + tr: "tr-TR", +}; + +export function normalizeV3SurveyLanguageTag(value: string): string | null { + const normalizedSeparators = value.trim().replaceAll("_", "-"); + + try { + const normalizedLanguage = Intl.getCanonicalLocales(normalizedSeparators)[0] ?? null; + if (!normalizedLanguage || !V3_SURVEY_LANGUAGE_TAG_REGEX.test(normalizedLanguage)) { + return null; + } + + return normalizedLanguage; + } catch { + return null; + } +} + +export function normalizeV3SurveyLanguageIdentifier(value: string): string | null { + return ( + normalizeV3SurveyLanguageTag(value) ?? V3_LEGACY_LANGUAGE_CODE_MAP[value.trim().toLowerCase()] ?? null + ); +} + +function getLanguageSelectorKey(value: string): string { + return normalizeV3SurveyLanguageTag(value)?.toLowerCase() ?? value.trim().toLowerCase(); +} + +export function parseV3SurveyLanguageQuery( + value: TV3SurveyLanguageQueryInput +): TParseV3SurveyLanguageQueryResult { + const requestedLanguages = (Array.isArray(value) ? value : [value]) + .flatMap((entry) => entry.split(",")) + .map((entry) => entry.trim()); + + if (requestedLanguages.some((entry) => entry.length === 0)) { + return { + ok: false, + message: "Language selector must contain valid comma-separated language selectors", + }; + } + + const languages: string[] = []; + const languageKeys = new Set(); + + for (const language of requestedLanguages) { + const languageKey = getLanguageSelectorKey(language); + + if (!languageKeys.has(languageKey)) { + languageKeys.add(languageKey); + languages.push(language); + } + } + + return { ok: true, languages }; +} + +function getLanguageBase(code: string): string | null { + const normalizedCode = normalizeV3SurveyLanguageIdentifier(code); + + if (normalizedCode) { + return normalizedCode.split("-")[0].toLowerCase(); + } + + return /^[a-z]{2,3}$/i.test(code) ? code.toLowerCase() : null; +} + +function isLanguageOnlySelector(code: string): boolean { + return /^[a-z]{2,3}$/i.test(code); +} + +function getNormalizedLanguage(language: TV3SurveyLanguageInput) { + const code = normalizeV3SurveyLanguageIdentifier(language.code) ?? language.code; + const alias = language.alias?.trim() || null; + + return { + ...language, + code, + originalCode: language.code, + alias, + normalizedAlias: alias ? normalizeV3SurveyLanguageIdentifier(alias) : null, + }; +} + +function createAmbiguousLanguageResult( + requestedLanguage: string, + normalizedCode: string | null, + matchingCodes: string[] +): TResolveV3SurveyLanguageCodeResult { + return { + ok: false, + reason: "ambiguous", + normalizedCode: normalizedCode ?? requestedLanguage, + message: `Language '${requestedLanguage}' is ambiguous for this survey. Matching languages: ${matchingCodes.join( + ", " + )}`, + }; +} + +export function resolveV3SurveyLanguageCode( + requestedLanguage: string, + languages: TV3SurveyLanguageInput[] +): TResolveV3SurveyLanguageCodeResult { + const requestedLanguageValue = requestedLanguage.trim(); + const requestedLanguageKey = requestedLanguageValue.toLowerCase(); + const normalizedRequestedLanguage = normalizeV3SurveyLanguageTag(requestedLanguageValue); + + const normalizedLanguages = languages.map(getNormalizedLanguage); + const requestedLanguageBase = getLanguageBase(requestedLanguageValue); + + if (isLanguageOnlySelector(requestedLanguageValue) && requestedLanguageBase) { + const baseMatchCodes = Array.from( + new Set( + normalizedLanguages + .filter((language) => getLanguageBase(language.code) === requestedLanguageBase) + .map((language) => language.code) + ) + ); + + if (baseMatchCodes.length > 1) { + return createAmbiguousLanguageResult( + requestedLanguageValue, + normalizedRequestedLanguage, + baseMatchCodes + ); + } + } + + let matches = normalizedLanguages.filter( + (language) => + language.originalCode.toLowerCase() === requestedLanguageKey || + language.alias?.toLowerCase() === requestedLanguageKey || + (normalizedRequestedLanguage && + (language.code.toLowerCase() === normalizedRequestedLanguage.toLowerCase() || + language.normalizedAlias?.toLowerCase() === normalizedRequestedLanguage.toLowerCase())) + ); + + if (matches.length === 0 && isLanguageOnlySelector(requestedLanguageValue) && requestedLanguageBase) { + matches = normalizedLanguages.filter( + (language) => getLanguageBase(language.code) === requestedLanguageBase + ); + } + + const matchCodes = Array.from(new Set(matches.map((language) => language.code))); + + if (matchCodes.length === 1) { + return { ok: true, code: matchCodes[0] }; + } + + if (matchCodes.length > 1) { + return createAmbiguousLanguageResult(requestedLanguageValue, normalizedRequestedLanguage, matchCodes); + } + + if (!normalizedRequestedLanguage) { + if (isLanguageOnlySelector(requestedLanguageValue)) { + return { + ok: false, + reason: "unknown", + normalizedCode: requestedLanguageValue, + message: `Language '${requestedLanguageValue}' is not configured for this survey`, + }; + } + + return { + ok: false, + reason: "invalid", + message: `Language '${requestedLanguageValue}' is not a valid locale code or configured language alias`, + }; + } + + return { + ok: false, + reason: "unknown", + normalizedCode: normalizedRequestedLanguage, + message: `Language '${normalizedRequestedLanguage}' is not configured for this survey`, + }; +} diff --git a/apps/web/app/api/v3/surveys/route.test.ts b/apps/web/app/api/v3/surveys/route.test.ts deleted file mode 100644 index 61204866e38e..000000000000 --- a/apps/web/app/api/v3/surveys/route.test.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { ApiKeyPermission } from "@prisma/client"; -import { NextRequest } from "next/server"; -import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors"; -import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth"; -import { getSurveyCount } from "@/modules/survey/list/lib/survey"; -import { encodeSurveyListPageCursor, getSurveyListPage } from "@/modules/survey/list/lib/survey-page"; -import { GET } from "./route"; - -const { mockAuthenticateRequest } = vi.hoisted(() => ({ - mockAuthenticateRequest: vi.fn(), -})); - -vi.mock("next-auth", () => ({ - getServerSession: vi.fn(), -})); - -vi.mock("@/app/api/v1/auth", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, authenticateRequest: mockAuthenticateRequest }; -}); - -vi.mock("@/modules/core/rate-limit/helpers", () => ({ - applyRateLimit: vi.fn().mockResolvedValue(undefined), - applyIPRateLimit: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/constants", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, AUDIT_LOG_ENABLED: false }; -}); - -vi.mock("@/app/api/v3/lib/auth", () => ({ - requireV3WorkspaceAccess: vi.fn(), -})); - -vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getSurveyListPage: vi.fn(), - }; -}); - -vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getSurveyCount: vi.fn(), - }; -}); - -vi.mock("@formbricks/logger", () => ({ - logger: { - withContext: vi.fn(() => ({ - warn: vi.fn(), - error: vi.fn(), - })), - }, -})); - -const getServerSession = vi.mocked((await import("next-auth")).getServerSession); - -const validWorkspaceId = "clxx1234567890123456789012"; - -function createRequest(url: string, requestId?: string, extraHeaders?: Record): NextRequest { - const headers: Record = { ...extraHeaders }; - if (requestId) headers["x-request-id"] = requestId; - return new NextRequest(url, { headers }); -} - -const apiKeyAuth = { - type: "apiKey" as const, - apiKeyId: "key_1", - organizationId: "org_1", - organizationAccess: { - accessControl: { read: true, write: false }, - }, - workspacePermissions: [ - { - workspaceId: "proj_1", - workspaceName: "P", - permission: ApiKeyPermission.read, - }, - ], -}; - -describe("GET /api/v3/surveys", () => { - beforeEach(() => { - vi.resetAllMocks(); - getServerSession.mockResolvedValue({ - user: { id: "user_1", name: "User", email: "u@example.com" }, - expires: "2026-01-01", - } as any); - mockAuthenticateRequest.mockResolvedValue(null); - vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, _workspaceId) => { - if (auth && "apiKeyId" in auth) { - const p = (auth as any).workspacePermissions?.find((e: any) => e.workspaceId === "proj_1"); - if (!p) { - return new Response( - JSON.stringify({ - title: "Forbidden", - status: 403, - detail: "You are not authorized to access this resource", - requestId: "req", - }), - { status: 403, headers: { "Content-Type": "application/problem+json" } } - ); - } - return { - workspaceId: p.workspaceId, - organizationId: (auth as any).organizationId, - }; - } - return { - workspaceId: "proj_1", - organizationId: "org_1", - }; - }); - vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null }); - vi.mocked(getSurveyCount).mockResolvedValue(0); - }); - - afterEach(() => { - vi.clearAllMocks(); - }); - - test("returns 401 when no session and no API key", async () => { - getServerSession.mockResolvedValue(null); - mockAuthenticateRequest.mockResolvedValue(null); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`); - const res = await GET(req, {} as any); - expect(res.status).toBe(401); - expect(res.headers.get("Content-Type")).toBe("application/problem+json"); - expect(requireV3WorkspaceAccess).not.toHaveBeenCalled(); - }); - - test("returns 200 with session and valid workspaceId", async () => { - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456"); - const res = await GET(req, {} as any); - expect(res.status).toBe(200); - expect(res.headers.get("Content-Type")).toBe("application/json"); - expect(res.headers.get("X-Request-Id")).toBe("req-456"); - expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( - expect.objectContaining({ user: expect.any(Object) }), - validWorkspaceId, - "read", - "req-456", - "/api/v3/surveys" - ); - expect(getSurveyListPage).toHaveBeenCalledWith("proj_1", { - limit: 20, - cursor: null, - sortBy: "updatedAt", - filterCriteria: undefined, - }); - expect(getSurveyCount).toHaveBeenCalledWith("proj_1", undefined); - }); - - test("returns 200 with x-api-key when workspace is on the key", async () => { - getServerSession.mockResolvedValue(null); - mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", { - "x-api-key": "fbk_test", - }); - const res = await GET(req, {} as any); - expect(res.status).toBe(200); - expect(requireV3WorkspaceAccess).toHaveBeenCalledWith( - expect.objectContaining({ apiKeyId: "key_1" }), - validWorkspaceId, - "read", - "req-k", - "/api/v3/surveys" - ); - expect(getSurveyListPage).toHaveBeenCalledWith("proj_1", { - limit: 20, - cursor: null, - sortBy: "updatedAt", - filterCriteria: undefined, - }); - expect(getSurveyCount).toHaveBeenCalledWith("proj_1", undefined); - }); - - test("returns 403 when API key does not include workspace", async () => { - getServerSession.mockResolvedValue(null); - mockAuthenticateRequest.mockResolvedValue({ - ...apiKeyAuth, - workspacePermissions: [ - { - workspaceId: "proj_x", - workspaceName: "X", - permission: ApiKeyPermission.read, - }, - ], - } as any); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, { - "x-api-key": "fbk_test", - }); - const res = await GET(req, {} as any); - expect(res.status).toBe(403); - }); - - test("returns 400 when the createdBy filter is used", async () => { - const req = createRequest( - `http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you` - ); - const res = await GET(req, {} as any); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true); - expect(requireV3WorkspaceAccess).not.toHaveBeenCalled(); - }); - - test("returns 400 when workspaceId is missing", async () => { - const req = createRequest("http://localhost/api/v3/surveys"); - const res = await GET(req, {} as any); - expect(res.status).toBe(400); - expect(requireV3WorkspaceAccess).not.toHaveBeenCalled(); - }); - - test("returns 400 when workspaceId is not cuid2", async () => { - const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid"); - const res = await GET(req, {} as any); - expect(res.status).toBe(400); - }); - - test("returns 400 when limit exceeds max", async () => { - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`); - const res = await GET(req, {} as any); - expect(res.status).toBe(400); - }); - - test("reflects limit, nextCursor, and totalCount in meta", async () => { - vi.mocked(getSurveyListPage).mockResolvedValue({ - surveys: [], - nextCursor: "cursor-123", - }); - vi.mocked(getSurveyCount).mockResolvedValue(42); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`); - const res = await GET(req, {} as any); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 }); - expect(getSurveyListPage).toHaveBeenCalledWith("proj_1", { - limit: 10, - cursor: null, - sortBy: "updatedAt", - filterCriteria: undefined, - }); - expect(getSurveyCount).toHaveBeenCalledWith("proj_1", undefined); - }); - - test("skips totalCount when includeTotalCount=false", async () => { - vi.mocked(getSurveyListPage).mockResolvedValue({ - surveys: [], - nextCursor: null, - }); - const cursor = encodeSurveyListPageCursor({ - version: 1, - sortBy: "updatedAt", - value: "2026-04-15T10:00:00.000Z", - id: "survey_1", - }); - - const req = createRequest( - `http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&cursor=${cursor}&includeTotalCount=false` - ); - const res = await GET(req, {} as any); - - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.meta).toEqual({ limit: 20, nextCursor: null, totalCount: null }); - expect(getSurveyCount).not.toHaveBeenCalled(); - }); - - test("passes filter query to getSurveyListPage", async () => { - const filterCriteria = { status: ["inProgress"] }; - const req = createRequest( - `http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt` - ); - const res = await GET(req, {} as any); - expect(res.status).toBe(200); - expect(getSurveyListPage).toHaveBeenCalledWith("proj_1", { - limit: 20, - cursor: null, - sortBy: "updatedAt", - filterCriteria, - }); - expect(getSurveyCount).toHaveBeenCalledWith("proj_1", filterCriteria); - }); - - test("returns 400 when filterCriteria is used", async () => { - const req = createRequest( - `http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}` - ); - const res = await GET(req, {} as any); - expect(res.status).toBe(400); - expect(requireV3WorkspaceAccess).not.toHaveBeenCalled(); - }); - - test("returns 403 when auth returns 403", async () => { - vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce( - new Response( - JSON.stringify({ - title: "Forbidden", - status: 403, - detail: "You are not authorized to access this resource", - requestId: "req-789", - }), - { status: 403, headers: { "Content-Type": "application/problem+json" } } - ) - ); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`); - const res = await GET(req, {} as any); - expect(res.status).toBe(403); - }); - - test("list items expose workspaceId and omit internal fields", async () => { - vi.mocked(getSurveyListPage).mockResolvedValue({ - surveys: [ - { - id: "s1", - name: "Survey 1", - workspaceId: "ws_1", - type: "link", - status: "draft", - createdAt: new Date(), - updatedAt: new Date(), - responseCount: 0, - creator: { name: "Test" }, - singleUse: null, - } as any, - ], - nextCursor: null, - }); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`); - const res = await GET(req, {} as any); - const body = await res.json(); - expect(body.data[0]).not.toHaveProperty("blocks"); - expect(body.data[0]).not.toHaveProperty("_count"); - expect(body.data[0]).not.toHaveProperty("singleUse"); - expect(body.data[0].id).toBe("s1"); - expect(body.data[0].workspaceId).toBe("ws_1"); - }); - - test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => { - vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1")); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf"); - const res = await GET(req, {} as any); - expect(res.status).toBe(403); - const body = await res.json(); - expect(body.code).toBe("forbidden"); - }); - - test("returns 500 when getSurveyListPage throws DatabaseError", async () => { - vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down")); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db"); - const res = await GET(req, {} as any); - expect(res.status).toBe(500); - const body = await res.json(); - expect(body.code).toBe("internal_server_error"); - }); - - test("returns 500 on unexpected error from getSurveyListPage", async () => { - vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom")); - const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err"); - const res = await GET(req, {} as any); - expect(res.status).toBe(500); - const body = await res.json(); - expect(body.code).toBe("internal_server_error"); - }); -}); diff --git a/apps/web/app/api/v3/surveys/serializers.test.ts b/apps/web/app/api/v3/surveys/serializers.test.ts new file mode 100644 index 000000000000..edef3a079d4d --- /dev/null +++ b/apps/web/app/api/v3/surveys/serializers.test.ts @@ -0,0 +1,585 @@ +import { describe, expect, test } from "vitest"; +import type { TSurvey } from "@formbricks/types/surveys/types"; +import { + V3SurveyLanguageError, + V3SurveyUnsupportedShapeError, + serializeV3SurveyResource, +} from "./serializers"; + +const baseSurvey = { + id: "survey_1", + workspaceId: "workspace_1", + createdAt: new Date("2026-04-21T10:00:00.000Z"), + updatedAt: new Date("2026-04-21T11:00:00.000Z"), + name: "Product Feedback", + type: "link", + status: "draft", + metadata: { cx: "enterprise" }, + languages: [ + { + default: true, + enabled: true, + language: { id: "lang_1", code: "en-US", alias: "en", createdAt: new Date(), updatedAt: new Date() }, + }, + { + default: false, + enabled: true, + language: { id: "lang_2", code: "de-DE", alias: "de", createdAt: new Date(), updatedAt: new Date() }, + }, + { + default: false, + enabled: false, + language: { id: "lang_3", code: "fr-FR", alias: "fr", createdAt: new Date(), updatedAt: new Date() }, + }, + ], + questions: [], + welcomeCard: { + enabled: true, + headline: { default: "Welcome", "de-DE": "Willkommen", "fr-FR": "Bienvenue" }, + }, + blocks: [ + { + id: "block_1", + name: "Intro", + elements: [ + { + id: "satisfaction", + type: "openText", + headline: { default: "What should we improve?", "de-DE": "Was sollen wir verbessern?" }, + subheader: { default: "Tell us more" }, + required: true, + }, + ], + }, + ], + endings: [], + hiddenFields: { enabled: false, fieldIds: [] }, + variables: [], +} as unknown as TSurvey; + +const createLegacyHindiSurvey = (overrides: Partial = {}) => + ({ + ...baseSurvey, + languages: [ + { + default: true, + enabled: true, + language: { + id: "lang_1", + code: "en", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + default: false, + enabled: true, + language: { + id: "lang_2", + code: "hi", + alias: "hi-in", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ], + welcomeCard: { + enabled: true, + headline: { default: "Welcome", hi: "स्वागत है" }, + }, + ...overrides, + }) as unknown as TSurvey; + +describe("serializeV3SurveyResource", () => { + test("returns multilingual fields using emitted survey language codes", () => { + const resource = serializeV3SurveyResource(baseSurvey); + + expect(resource.defaultLanguage).toBe("en-US"); + expect(resource).not.toHaveProperty("language"); + expect(resource.languages).toEqual([ + { code: "en-US", default: true, enabled: true, alias: "en" }, + { code: "de-DE", default: false, enabled: true, alias: "de" }, + { code: "fr-FR", default: false, enabled: false, alias: "fr" }, + ]); + expect(resource).toMatchObject({ + welcomeCard: { + headline: { + "en-US": "Welcome", + "de-DE": "Willkommen", + "fr-FR": "Bienvenue", + }, + }, + }); + expect(resource).toMatchObject({ + blocks: [ + { + elements: [ + { + headline: { + "en-US": "What should we improve?", + "de-DE": "Was sollen wir verbessern?", + }, + }, + ], + }, + ], + }); + }); + + test("does not expose the internal default pseudo-locale for surveys without configured languages", () => { + const survey = { + ...baseSurvey, + languages: [], + welcomeCard: { + enabled: true, + headline: { default: "Welcome" }, + }, + blocks: [ + { + id: "block_1", + name: "Intro", + elements: [ + { + id: "satisfaction", + type: "openText", + headline: { default: "What should we improve?" }, + required: true, + }, + ], + }, + ], + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(survey); + + expect(resource.defaultLanguage).toBe("en-US"); + expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true }]); + expect(resource).toMatchObject({ + welcomeCard: { headline: { "en-US": "Welcome" } }, + blocks: [ + { + elements: [ + { + headline: { "en-US": "What should we improve?" }, + }, + ], + }, + ], + }); + }); + + test("filters the implicit default language for surveys without configured languages", () => { + const survey = { + ...baseSurvey, + languages: [], + welcomeCard: { + enabled: true, + headline: { default: "Welcome" }, + }, + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(survey, { lang: ["en-US"] }); + + expect(resource).not.toHaveProperty("language"); + expect(resource).toMatchObject({ welcomeCard: { headline: { "en-US": "Welcome" } } }); + }); + + test("preserves stored locale variants when their keys use non-canonical casing or separators", () => { + const survey = { + ...baseSurvey, + welcomeCard: { + enabled: true, + headline: { default: "Welcome", de_de: "Willkommen" }, + }, + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(survey); + + expect(resource).toMatchObject({ + welcomeCard: { + headline: { + "en-US": "Welcome", + "de-DE": "Willkommen", + }, + }, + }); + }); + + test("filters fields for case-insensitive underscore language selectors while preserving maps", () => { + const resource = serializeV3SurveyResource(baseSurvey, { lang: ["DE_de"] }); + + expect(resource).not.toHaveProperty("language"); + expect(resource).toMatchObject({ + welcomeCard: { headline: { "de-DE": "Willkommen" } }, + blocks: [ + { + elements: [ + { + headline: { "de-DE": "Was sollen wir verbessern?" }, + subheader: { "de-DE": "Tell us more" }, + }, + ], + }, + ], + }); + }); + + test("filters script-region locale selectors while preserving maps", () => { + const survey = { + ...baseSurvey, + languages: [ + ...baseSurvey.languages, + { + default: false, + enabled: true, + language: { + id: "lang_4", + code: "zh-Hans-CN", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ], + welcomeCard: { + enabled: true, + headline: { default: "Welcome", zh_hans_cn: "欢迎" }, + }, + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(survey, { lang: ["ZH_hans_cn"] }); + + expect(resource).toMatchObject({ + welcomeCard: { headline: { "zh-Hans-CN": "欢迎" } }, + }); + }); + + test("filters disabled configured languages for management reads", () => { + const resource = serializeV3SurveyResource(baseSurvey, { lang: ["fr-FR"] }); + + expect(resource).toMatchObject({ welcomeCard: { headline: { "fr-FR": "Bienvenue" } } }); + }); + + test("filters multiple requested languages while preserving maps", () => { + const resource = serializeV3SurveyResource(baseSurvey, { lang: ["en-US", "de-DE"] }); + + expect(resource).not.toHaveProperty("language"); + expect(resource).toMatchObject({ + welcomeCard: { + headline: { + "en-US": "Welcome", + "de-DE": "Willkommen", + }, + }, + blocks: [ + { + elements: [ + { + headline: { + "en-US": "What should we improve?", + "de-DE": "Was sollen wir verbessern?", + }, + }, + ], + }, + ], + }); + }); + + test("filters fields for configured language aliases", () => { + const resource = serializeV3SurveyResource(baseSurvey, { lang: ["de"] }); + + expect(resource).toMatchObject({ + welcomeCard: { headline: { "de-DE": "Willkommen" } }, + blocks: [ + { + elements: [ + { + headline: { "de-DE": "Was sollen wir verbessern?" }, + }, + ], + }, + ], + }); + }); + + test("filters fields for non-locale configured language aliases", () => { + const survey = { + ...baseSurvey, + languages: [ + { + default: true, + enabled: true, + language: { + id: "lang_1", + code: "en-US", + alias: "english", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ], + welcomeCard: { + enabled: true, + headline: { default: "Welcome" }, + }, + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(survey, { lang: ["english"] }); + + expect(resource.languages).toEqual([{ code: "en-US", default: true, enabled: true, alias: "english" }]); + expect(resource).toMatchObject({ + welcomeCard: { headline: { "en-US": "Welcome" } }, + }); + }); + + test("trims configured language aliases and omits blank aliases", () => { + const survey = { + ...baseSurvey, + languages: [ + { + default: true, + enabled: true, + language: { + id: "lang_1", + code: "en-US", + alias: " english ", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + default: false, + enabled: true, + language: { + id: "lang_2", + code: "de-DE", + alias: " ", + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ], + welcomeCard: { + enabled: true, + headline: { default: "Welcome", "de-DE": "Willkommen" }, + }, + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(survey, { lang: ["english"] }); + + expect(resource.languages).toEqual([ + { code: "en-US", default: true, enabled: true, alias: "english" }, + { code: "de-DE", default: false, enabled: true }, + ]); + expect(resource).toMatchObject({ + welcomeCard: { headline: { "en-US": "Welcome" } }, + }); + }); + + test("maps known legacy stored language codes and translation keys to emitted response codes", () => { + const survey = createLegacyHindiSurvey({ + blocks: [ + { + id: "block_1", + name: "Intro", + elements: [ + { + id: "satisfaction", + type: "openText", + headline: { default: "What should we improve?", hi: "हमें क्या सुधारना चाहिए?" }, + required: true, + }, + ], + }, + ], + }); + + const resource = serializeV3SurveyResource(survey, { lang: ["hi-IN"] }); + + expect(resource.defaultLanguage).toBe("en-US"); + expect(resource.languages).toEqual([ + { code: "en-US", default: true, enabled: true }, + { code: "hi-IN", default: false, enabled: true, alias: "hi-in" }, + ]); + expect(resource).toMatchObject({ + welcomeCard: { headline: { "hi-IN": "स्वागत है" } }, + blocks: [ + { + elements: [ + { + headline: { "hi-IN": "हमें क्या सुधारना चाहिए?" }, + }, + ], + }, + ], + }); + }); + + test("filters legacy stored language codes by legacy code and alias", () => { + const survey = createLegacyHindiSurvey(); + + expect(serializeV3SurveyResource(survey, { lang: ["hi"] })).toMatchObject({ + welcomeCard: { headline: { "hi-IN": "स्वागत है" } }, + }); + expect(serializeV3SurveyResource(survey, { lang: ["hi-in"] })).toMatchObject({ + welcomeCard: { headline: { "hi-IN": "स्वागत है" } }, + }); + expect(serializeV3SurveyResource(survey, { lang: ["HI_in"] })).toMatchObject({ + welcomeCard: { headline: { "hi-IN": "स्वागत है" } }, + }); + }); + + test("resolves language-only selectors and emits configured language-only map keys", () => { + const survey = { + ...baseSurvey, + languages: [ + { + default: true, + enabled: true, + language: { + id: "lang_1", + code: "vi", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ], + welcomeCard: { + enabled: true, + headline: { default: "Chào mừng" }, + }, + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(survey, { lang: ["vi"] }); + + expect(resource.defaultLanguage).toBe("vi"); + expect(resource.languages).toEqual([{ code: "vi", default: true, enabled: true }]); + expect(resource).toMatchObject({ + welcomeCard: { headline: { vi: "Chào mừng" } }, + }); + }); + + test("resolves script-only selectors and emits configured script-only map keys", () => { + const survey = { + ...baseSurvey, + languages: [ + { + default: true, + enabled: true, + language: { + id: "lang_1", + code: "zh-Hans", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ], + welcomeCard: { + enabled: true, + headline: { default: "欢迎" }, + }, + } as unknown as TSurvey; + + const resource = serializeV3SurveyResource(survey, { lang: ["zh_Hans"] }); + + expect(resource.defaultLanguage).toBe("zh-Hans"); + expect(resource.languages).toEqual([{ code: "zh-Hans", default: true, enabled: true }]); + expect(resource).toMatchObject({ + welcomeCard: { headline: { "zh-Hans": "欢迎" } }, + }); + }); + + test("rejects ambiguous language-only selectors", () => { + const survey = { + ...baseSurvey, + languages: [ + { + default: true, + enabled: true, + language: { + id: "lang_1", + code: "en-US", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + { + default: false, + enabled: true, + language: { + id: "lang_2", + code: "en-GB", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ], + } as unknown as TSurvey; + + expect(() => serializeV3SurveyResource(survey, { lang: ["en"] })).toThrow( + "Language 'en' is ambiguous for this survey. Matching languages: en-US, en-GB" + ); + }); + + test("does not fallback full locale selectors to another configured region", () => { + const survey = { + ...baseSurvey, + languages: [ + { + default: true, + enabled: true, + language: { + id: "lang_1", + code: "pt-BR", + alias: null, + createdAt: new Date(), + updatedAt: new Date(), + }, + }, + ], + welcomeCard: { + enabled: true, + headline: { default: "Boas-vindas" }, + }, + } as unknown as TSurvey; + + expect(() => serializeV3SurveyResource(survey, { lang: ["pt-PT"] })).toThrow( + "Language 'pt-PT' is not configured for this survey" + ); + }); + + test("exposes the normalized locale code for unknown language errors", () => { + try { + serializeV3SurveyResource(baseSurvey, { lang: ["ES_es"] }); + } catch (error) { + if (!(error instanceof V3SurveyLanguageError)) { + throw error; + } + + expect(error.message).toBe("Language 'es-ES' is not configured for this survey"); + expect(error.normalizedCode).toBe("es-ES"); + return; + } + + throw new Error("Expected V3SurveyLanguageError"); + }); + + test("rejects legacy question-based survey shapes instead of returning an incomplete block resource", () => { + const survey = { + ...baseSurvey, + questions: [{ id: "legacy_question", type: "openText", headline: { default: "Legacy question" } }], + blocks: [], + } as unknown as TSurvey; + + expect(() => serializeV3SurveyResource(survey)).toThrow(V3SurveyUnsupportedShapeError); + expect(() => serializeV3SurveyResource(survey)).toThrow( + "Legacy question-based surveys are not supported by the v3 survey management API" + ); + }); +}); diff --git a/apps/web/app/api/v3/surveys/serializers.ts b/apps/web/app/api/v3/surveys/serializers.ts index a003b186f374..f9d24d52c77e 100644 --- a/apps/web/app/api/v3/surveys/serializers.ts +++ b/apps/web/app/api/v3/surveys/serializers.ts @@ -1,13 +1,206 @@ -import type { TSurvey } from "@/modules/survey/list/types/surveys"; +import type { TSurvey as TInternalSurvey } from "@formbricks/types/surveys/types"; +import type { TSurvey as TSurveyListRecord } from "@/modules/survey/list/types/surveys"; +import { normalizeV3SurveyLanguageIdentifier, resolveV3SurveyLanguageCode } from "./language"; -export type TV3SurveyListItem = Omit; +export type TV3SurveyListItem = Omit; +const DEFAULT_V3_SURVEY_LANGUAGE = "en-US"; + +type TV3SurveyLanguage = { + code: string; + default: boolean; + enabled: boolean; + alias?: string | null; +}; + +type TSerializedValue = + | string + | number + | boolean + | null + | TSerializedValue[] + | { [key: string]: TSerializedValue }; + +export class V3SurveyLanguageError extends Error { + constructor( + message: string, + readonly normalizedCode?: string + ) { + super(message); + this.name = "V3SurveyLanguageError"; + } +} + +export class V3SurveyUnsupportedShapeError extends Error { + constructor(message: string) { + super(message); + this.name = "V3SurveyUnsupportedShapeError"; + } +} /** * Keep the v3 API contract isolated from internal persistence naming. * Surveys are scoped by workspaceId. */ -export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem { +export function serializeV3SurveyListItem(survey: TSurveyListRecord): TV3SurveyListItem { const { singleUse: _omitSingleUse, ...rest } = survey; return rest; } + +function toIsoString(value: Date | string): string { + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + +function getSurveyLanguages(survey: TInternalSurvey): TV3SurveyLanguage[] { + const languages = (survey.languages ?? []).map((surveyLanguage) => { + const alias = surveyLanguage.language.alias?.trim() || null; + + return { + code: normalizeV3SurveyLanguageIdentifier(surveyLanguage.language.code) ?? surveyLanguage.language.code, + default: surveyLanguage.default, + enabled: surveyLanguage.enabled, + alias, + }; + }); + + if (languages.length === 0) { + return [{ code: DEFAULT_V3_SURVEY_LANGUAGE, default: true, enabled: true }]; + } + + return languages; +} + +function getDefaultLanguage(survey: TInternalSurvey): string { + const defaultLanguageCode = survey.languages?.find((surveyLanguage) => surveyLanguage.default)?.language + .code; + return defaultLanguageCode + ? (normalizeV3SurveyLanguageIdentifier(defaultLanguageCode) ?? defaultLanguageCode) + : DEFAULT_V3_SURVEY_LANGUAGE; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isI18nString(value: unknown): value is Record { + return ( + isPlainObject(value) && + typeof value.default === "string" && + Object.values(value).every((entry) => typeof entry === "string") + ); +} + +function getI18nValueForLanguage(value: Record, languageCode: string): string | undefined { + if (typeof value[languageCode] === "string") { + return value[languageCode]; + } + + const matchingKey = Object.keys(value).find( + (key) => normalizeV3SurveyLanguageIdentifier(key)?.toLowerCase() === languageCode.toLowerCase() + ); + return matchingKey ? value[matchingKey] : undefined; +} + +function serializeCanonicalValue( + value: unknown, + defaultLanguage: string, + languageCodes: Set, + options?: { fallbackMissingTranslations?: boolean } +): TSerializedValue { + if (isI18nString(value)) { + const result: Record = { + [defaultLanguage]: value.default, + }; + + for (const languageCode of languageCodes) { + const translatedValue = getI18nValueForLanguage(value, languageCode); + if (languageCode !== defaultLanguage) { + if (translatedValue !== undefined) { + result[languageCode] = translatedValue; + } else if (options?.fallbackMissingTranslations) { + result[languageCode] = value.default; + } + } + } + + if (!languageCodes.has(defaultLanguage)) { + delete result[defaultLanguage]; + } + + return result; + } + + if (Array.isArray(value)) { + return value.map((entry) => serializeCanonicalValue(entry, defaultLanguage, languageCodes, options)); + } + + if (isPlainObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + serializeCanonicalValue(entry, defaultLanguage, languageCodes, options), + ]) + ); + } + + return value as TSerializedValue; +} + +function resolveRequestedLanguage(languages: TV3SurveyLanguage[], language: string): string { + const result = resolveV3SurveyLanguageCode(language, languages); + + if (!result.ok) { + throw new V3SurveyLanguageError(result.message, result.normalizedCode); + } + + return result.code; +} + +function resolveRequestedLanguages(languages: TV3SurveyLanguage[], requestedLanguages?: string[]): string[] { + if (!requestedLanguages) { + return []; + } + + return requestedLanguages.map((language) => resolveRequestedLanguage(languages, language)); +} + +export function serializeV3SurveyResource(survey: TInternalSurvey, options?: { lang?: string[] }) { + if (Array.isArray(survey.questions) && survey.questions.length > 0) { + throw new V3SurveyUnsupportedShapeError( + "Legacy question-based surveys are not supported by the v3 survey management API" + ); + } + + const defaultLanguage = getDefaultLanguage(survey); + const languages = getSurveyLanguages(survey); + const configuredLanguageCodes = new Set(languages.map((language) => language.code)); + const requestedLanguages = resolveRequestedLanguages(languages, options?.lang); + const languageCodes = requestedLanguages.length > 0 ? new Set(requestedLanguages) : configuredLanguageCodes; + const serializeValue = (value: unknown) => + serializeCanonicalValue(value, defaultLanguage, languageCodes, { + fallbackMissingTranslations: requestedLanguages.length > 0, + }); + + return { + id: survey.id, + workspaceId: survey.workspaceId, + createdAt: toIsoString(survey.createdAt), + updatedAt: toIsoString(survey.updatedAt), + name: survey.name, + type: survey.type, + status: survey.status, + metadata: survey.metadata, + defaultLanguage, + languages: languages.map(({ code, default: isDefault, enabled, alias }) => ({ + code, + default: isDefault, + enabled, + ...(alias ? { alias } : {}), + })), + welcomeCard: serializeValue(survey.welcomeCard), + blocks: serializeValue(survey.blocks), + endings: serializeValue(survey.endings), + hiddenFields: survey.hiddenFields, + variables: survey.variables, + }; +} diff --git a/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts b/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts index 082cb31fd993..6ec96cbb0c48 100644 --- a/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts +++ b/apps/web/modules/survey/list/hooks/use-delete-survey.test.ts @@ -99,12 +99,7 @@ describe("useDeleteSurvey", () => { 0 ); - resolveFetch?.( - new Response(JSON.stringify({ data: { id: "survey_1" } }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }) - ); + resolveFetch?.(new Response(null, { status: 204 })); await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: surveyKeys.lists() }); diff --git a/apps/web/modules/survey/list/lib/v3-surveys-client.test.ts b/apps/web/modules/survey/list/lib/v3-surveys-client.test.ts index b9792969cb09..317b25e14b59 100644 --- a/apps/web/modules/survey/list/lib/v3-surveys-client.test.ts +++ b/apps/web/modules/survey/list/lib/v3-surveys-client.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, test } from "vitest"; -import { buildSurveyListSearchParams } from "./v3-surveys-client"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { V3ApiError } from "@/modules/api/lib/v3-client"; +import { buildSurveyListSearchParams, deleteSurvey } from "./v3-surveys-client"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); describe("buildSurveyListSearchParams", () => { test("emits only supported v3 params using normalized filter values", () => { @@ -39,3 +44,39 @@ describe("buildSurveyListSearchParams", () => { ); }); }); + +describe("deleteSurvey", () => { + test("treats 204 No Content as a successful delete", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + + await expect(deleteSurvey("survey_1")).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledWith("/api/v3/surveys/survey_1", { + method: "DELETE", + cache: "no-store", + }); + }); + + test("maps v3 problem responses to V3ApiError", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + Response.json( + { + status: 403, + detail: "You are not authorized to access this resource", + code: "forbidden", + }, + { status: 403 } + ) + ) + ); + + await expect(deleteSurvey("survey_1")).rejects.toMatchObject({ + status: 403, + detail: "You are not authorized to access this resource", + code: "forbidden", + }); + }); +}); diff --git a/apps/web/modules/survey/list/lib/v3-surveys-client.ts b/apps/web/modules/survey/list/lib/v3-surveys-client.ts index ac173465fdcd..47ebcd7777d6 100644 --- a/apps/web/modules/survey/list/lib/v3-surveys-client.ts +++ b/apps/web/modules/survey/list/lib/v3-surveys-client.ts @@ -13,12 +13,6 @@ type TV3SurveyListResponse = { meta: TSurveyListPage["meta"]; }; -type TV3DeleteSurveyResponse = { - data: { - id: string; - }; -}; - export type TSurveyListPage = { data: TSurveyListItem[]; meta: { @@ -122,7 +116,7 @@ export async function listSurveys({ }; } -export async function deleteSurvey(surveyId: string): Promise<{ id: string }> { +export async function deleteSurvey(surveyId: string): Promise { const response = await fetch(`/api/v3/surveys/${surveyId}`, { method: "DELETE", cache: "no-store", @@ -131,7 +125,4 @@ export async function deleteSurvey(surveyId: string): Promise<{ id: string }> { if (!response.ok) { throw await parseV3ApiError(response); } - - const body = (await response.json()) as TV3DeleteSurveyResponse; - return body.data; } diff --git a/docs/api-v3-reference/openapi.yml b/docs/api-v3-reference/openapi.yml index 363bb3271515..fa985648eb93 100644 --- a/docs/api-v3-reference/openapi.yml +++ b/docs/api-v3-reference/openapi.yml @@ -1,19 +1,16 @@ # V3 API — Surveys (hand-maintained; not generated by generate-api-specs). # Implementation: apps/web/app/api/v3/surveys/route.ts and apps/web/app/api/v3/surveys/[surveyId]/route.ts -# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context. openapi: 3.1.0 info: title: Formbricks API v3 description: | - **GET /api/v3/surveys** and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace). + **GET /api/v3/surveys**, **GET /api/v3/surveys/{surveyId}**, and **DELETE /api/v3/surveys/{surveyId}** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace). **Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`). **workspaceId** Query param `workspaceId` is the canonical container identifier for this API. - **Deprecated compatibility:** the older `environmentId` identifier is still accepted - for compatibility but should no longer be used in new integrations. **Auth** Authenticate with either a session cookie or **`x-api-key`**. In dual-auth mode, V3 checks the API key first when the header is present, otherwise it uses the session path. Unauthenticated callers get **401** before query validation. @@ -34,7 +31,7 @@ info: The v3-backed survey overview page intentionally removes actions that are not yet exposed by this contract: `Created by` filtering, `Duplicate`, `Copy...`, `Preview`, and `Copy link`. **Next steps (out of scope for this spec)** - Additional v3 survey endpoints, optional ETag/304, field selection — see Survey-Server-Actions.md Part III. + Additional v3 survey write endpoints, optional ETag/304, field selection, and survey version history. version: 0.1.0 x-implementation-notes: route: apps/web/app/api/v3/surveys/route.ts @@ -51,7 +48,6 @@ paths: summary: List surveys description: | Returns surveys for the workspace. Session cookie or x-api-key. - Note: Environments are deprecated. Use workspace/workspaceId terminology. tags: - V3 Surveys parameters: @@ -63,15 +59,6 @@ paths: format: cuid2 description: | Workspace identifier. This is the canonical container ID for v3 APIs. - - in: query - name: environmentId - required: false - deprecated: true - schema: - type: string - format: cuid2 - description: | - Deprecated: use `workspaceId`. This alias is retained for backward compatibility. - in: query name: limit schema: @@ -198,6 +185,182 @@ paths: - sessionAuth: [] - apiKeyAuth: [] /api/v3/surveys/{surveyId}: + get: + operationId: getSurveyV3 + summary: Retrieve a survey + description: | + Returns the public v3 survey management resource for one survey. By default, translatable + fields are returned as stable multilingual maps keyed by the language codes emitted in + `languages[].code`. Use `lang` to filter those maps to one or more requested language selectors. + tags: + - V3 Surveys + parameters: + - in: path + name: surveyId + required: true + schema: + type: string + format: cuid2 + description: Survey identifier. + - in: query + name: lang + required: false + style: form + explode: false + schema: + type: array + items: + type: string + examples: + - [de-DE] + - [de-DE, pt-PT] + - [de] + - [zh-Hans] + - [zh-Hans-CN] + description: | + Comma-separated language selector filter for translatable fields, for example `?lang=de-DE,pt-PT`. + The response shape stays stable: translatable fields are always maps, never strings, and response + keys match the emitted `languages[].code` values for this survey. + For compatibility with existing Formbricks surveys, GET accepts language tags such as `de`, + `de-DE`, `zh-Hans`, and `zh-Hans-CN`, accepts `_` or `-` separators, is case-insensitive, and + accepts configured workspace aliases such as `english`. Bare language selectors are resolved + against the survey's configured languages and return `400` if ambiguous, for example if both + `en-US` and `en-GB` are configured. + Disabled-but-configured languages are readable in the management API so unfinished translations can + be completed. + responses: + "200": + description: Survey retrieved successfully + headers: + X-Request-Id: + schema: { type: string } + description: Request correlation ID + Cache-Control: + schema: { type: string } + example: "private, no-store" + content: + application/json: + schema: + type: object + required: [data] + properties: + data: + $ref: "#/components/schemas/SurveyResource" + examples: + multilingual: + summary: Multilingual authoring resource + value: + data: + id: clseedsurveycsat000000 + workspaceId: clseedworkspace000000000 + createdAt: "2026-05-18T09:24:54.014Z" + updatedAt: "2026-05-18T09:24:54.014Z" + name: CSAT Survey + type: link + status: inProgress + metadata: {} + defaultLanguage: en-US + languages: + - code: en-US + default: true + enabled: true + - code: de-DE + alias: german + default: false + enabled: false + welcomeCard: + enabled: false + blocks: + - id: e0tfwzqk63op37y14z95qq3k + name: Main Block + elements: + - id: nzte4cm8836hgjw63pesziht + type: rating + range: 5 + scale: smiley + headline: + en-US: How satisfied are you with our product? + de-DE: Wie zufrieden sind Sie mit unserem Produkt? + required: true + endings: [] + hiddenFields: + enabled: false + variables: [] + filtered: + summary: Language-filtered projection with ?lang=de-DE + value: + data: + id: clseedsurveycsat000000 + workspaceId: clseedworkspace000000000 + createdAt: "2026-05-18T09:24:54.014Z" + updatedAt: "2026-05-18T09:24:54.014Z" + name: CSAT Survey + type: link + status: inProgress + metadata: {} + defaultLanguage: en-US + languages: + - code: en-US + default: true + enabled: true + - code: de-DE + alias: german + default: false + enabled: false + welcomeCard: + enabled: false + blocks: + - id: e0tfwzqk63op37y14z95qq3k + name: Main Block + elements: + - id: nzte4cm8836hgjw63pesziht + type: rating + range: 5 + scale: smiley + headline: + de-DE: Wie zufrieden sind Sie mit unserem Produkt? + required: true + endings: [] + hiddenFields: + enabled: false + variables: [] + "400": + description: Invalid survey id, unsupported query parameter, unknown language, or unsupported legacy survey shape + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "401": + description: Not authenticated (no valid session or API key) + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "403": + description: Forbidden — no access, or survey does not exist (404 not used; avoids existence leak) + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "429": + description: Rate limit exceeded + headers: + Retry-After: + schema: { type: integer } + description: Seconds until the current rate-limit window resets + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + "500": + description: Internal Server Error + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + security: + - sessionAuth: [] + - apiKeyAuth: [] delete: operationId: deleteSurveyV3 summary: Delete a survey @@ -213,7 +376,7 @@ paths: format: cuid2 description: Survey identifier. responses: - "200": + "204": description: Survey deleted successfully headers: X-Request-Id: @@ -222,10 +385,6 @@ paths: Cache-Control: schema: { type: string } example: "private, no-store" - content: - application/json: - schema: - $ref: "#/components/schemas/SurveyDeleteResponse" "400": description: Bad Request content: @@ -304,15 +463,700 @@ components: properties: enabled: { type: boolean } isEncrypted: { type: boolean } - SurveyDeleteResponse: + TranslatableText: + allOf: + - $ref: "#/components/schemas/TranslatableTextMap" + description: | + Survey authoring text. `GET /api/v3/surveys/{surveyId}` always returns maps keyed by the emitted + `languages[].code` values for this survey. Use `?lang=` to filter which language keys are included. + The internal storage key `default` is never exposed by v3. + examples: + - en-US: What should we improve? + de-DE: Was sollten wir verbessern? + TranslatableTextMap: + type: object + description: Multilingual text map keyed by the emitted `languages[].code` values for this survey. + propertyNames: + type: string + description: Survey language code/tag, for example `en-US`, `de-DE`, `vi`, or `zh-Hans`. + additionalProperties: + type: string + SurveyLanguage: + type: object + description: | + Language configured for this survey. GET responses expose the server-emitted code/tag used as the + translatable map key. Existing surveys can use region-qualified, language-only, or script-only codes. + Disabled languages can still be read by the management API so unfinished translations can be completed. + required: [code, default, enabled] + properties: + code: + type: string + description: Server-emitted survey language code/tag used as the translatable map key. + example: en-US + alias: + type: string + nullable: true + description: Optional configured alias accepted by `?lang` for compatibility and agent discovery. + example: english + default: + type: boolean + description: Whether this is the default authoring language. + enabled: + type: boolean + description: Whether this language is enabled for respondent-facing delivery. + SurveyWelcomeCard: + type: object + description: Optional card shown before the first survey block. + required: [enabled] + properties: + enabled: + type: boolean + headline: + $ref: "#/components/schemas/TranslatableText" + subheader: + $ref: "#/components/schemas/TranslatableText" + buttonLabel: + $ref: "#/components/schemas/TranslatableText" + fileUrl: + type: string + videoUrl: + type: string + timeToFinish: + type: boolean + showResponseCount: + type: boolean + additionalProperties: true + SurveyHiddenFields: + type: object + description: | + Hidden fields, sometimes called embedded data in other survey products. Field ids are stable + public identifiers and may be referenced by logic, recall, quotas, integrations, and response data. + Use only letters, numbers, underscores, and hyphens; avoid spaces and reserved ids. + required: [enabled] + properties: + enabled: + type: boolean + fieldIds: + type: array + items: + type: string + pattern: "^[a-zA-Z0-9_-]+$" + uniqueItems: true + additionalProperties: false + SurveyVariable: + oneOf: + - $ref: "#/components/schemas/SurveyNumberVariable" + - $ref: "#/components/schemas/SurveyTextVariable" + description: | + Survey variable. Variable ids are stable references used by logic and calculation actions. + Variable names are human-readable labels and must be unique within the survey. + SurveyNumberVariable: + type: object + description: | + Number variable. Used by `calculate` logic actions with numeric operators such as `add`, + `subtract`, `multiply`, `divide`, or `assign`. + required: [id, name, type, value] + properties: + id: + type: string + format: cuid2 + description: Stable variable id referenced from logic. + name: + type: string + pattern: "^[a-z0-9_]+$" + description: Unique variable name. Lowercase letters, numbers, and underscores only. + type: + type: string + enum: [number] + value: + type: number + description: Default numeric value. + additionalProperties: false + SurveyTextVariable: + type: object + description: | + Text variable. Used by `calculate` logic actions with text operators such as `assign` or `concat`. + required: [id, name, type, value] + properties: + id: + type: string + format: cuid2 + description: Stable variable id referenced from logic. + name: + type: string + pattern: "^[a-z0-9_]+$" + description: Unique variable name. Lowercase letters, numbers, and underscores only. + type: + type: string + enum: [text] + value: + type: string + description: Default text value. + additionalProperties: false + SurveyEnding: + type: object + description: Ending reached after the last block or a jump action. + required: [id, type] + properties: + id: + type: string + format: cuid2 + description: Stable ending id. `jumpToBlock.target` may point to this id. + type: + type: string + enum: [endScreen, redirectToUrl] + headline: + $ref: "#/components/schemas/TranslatableText" + subheader: + $ref: "#/components/schemas/TranslatableText" + buttonLabel: + $ref: "#/components/schemas/TranslatableText" + buttonLink: + type: string + imageUrl: + type: string + videoUrl: + type: string + url: + type: string + description: Redirect URL for `redirectToUrl` endings. + label: + type: string + description: Optional internal label for redirect endings. + additionalProperties: true + SurveyBlock: type: object - required: [data] + description: | + Block-based survey section. Block ids are stable public identifiers. Logic and fallbacks can + jump to block ids or ending ids, so clients and agents should preserve ids unless intentionally + creating/deleting a block. + required: [id, name, elements] + properties: + id: + type: string + format: cuid2 + description: Stable block id. + name: + type: string + minLength: 1 + elements: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/SurveyElement" + logic: + type: array + items: + $ref: "#/components/schemas/SurveyBlockLogic" + logicFallback: + type: string + format: cuid2 + description: Block or ending id used when no logic condition matches. + buttonLabel: + $ref: "#/components/schemas/TranslatableText" + backButtonLabel: + $ref: "#/components/schemas/TranslatableText" + additionalProperties: true + SurveyElement: + type: object + description: | + Survey element/question inside a block. Element ids are stable public identifiers used by + logic, recall strings, response data, quotas, integrations, and analysis. The schema lists + the fields used by all current element types; type-specific fields are present only when relevant. + required: [id, type, headline, required] properties: - data: + id: + type: string + pattern: "^[a-zA-Z0-9_-]+$" + description: Stable element id. Avoid spaces and reserved ids. + type: + type: string + enum: + - openText + - multipleChoiceSingle + - multipleChoiceMulti + - nps + - rating + - csat + - ces + - consent + - pictureSelection + - cta + - date + - fileUpload + - cal + - matrix + - address + - ranking + - contactInfo + headline: + $ref: "#/components/schemas/TranslatableText" + subheader: + $ref: "#/components/schemas/TranslatableText" + required: + type: boolean + imageUrl: + type: string + videoUrl: + type: string + isDraft: + type: boolean + description: Draft marker used by the editor and future update rules. + placeholder: + $ref: "#/components/schemas/TranslatableText" + longAnswer: + type: boolean + description: "`openText` only." + inputType: + type: string + enum: [text, email, url, number, phone] + description: "`openText` only." + charLimit: type: object - required: [id] + description: "`openText` character limit configuration." properties: - id: { type: string } + enabled: + type: boolean + min: + type: number + max: + type: number + additionalProperties: false + choices: + type: array + description: Choice list for multiple choice, ranking, and picture selection elements. + items: + oneOf: + - $ref: "#/components/schemas/SurveyChoice" + - $ref: "#/components/schemas/SurveyPictureChoice" + shuffleOption: + type: string + enum: [none, all, exceptLast, reverseOrderOccasionally, reverseOrderExceptLast] + displayType: + type: string + enum: [list, dropdown] + description: Multiple choice display style. + otherOptionPlaceholder: + $ref: "#/components/schemas/TranslatableText" + lowerLabel: + $ref: "#/components/schemas/TranslatableText" + upperLabel: + $ref: "#/components/schemas/TranslatableText" + isColorCodingEnabled: + type: boolean + scale: + type: string + enum: [number, smiley, star] + description: Rating, CSAT, CES, or NPS scale display. + range: + type: integer + enum: [3, 4, 5, 6, 7, 10] + description: Rating range. CSAT is always 5; CES is 5 or 7. + label: + $ref: "#/components/schemas/TranslatableText" + description: Consent checkbox label. + allowMulti: + type: boolean + description: "`pictureSelection` only." + buttonExternal: + type: boolean + description: "`cta` only." + buttonUrl: + type: string + description: "`cta` only." + ctaButtonLabel: + $ref: "#/components/schemas/TranslatableText" + html: + $ref: "#/components/schemas/TranslatableText" + description: "`date` helper copy." + format: + type: string + enum: [M-d-y, d-M-y, y-M-d] + description: "`date` only." + allowMultipleFiles: + type: boolean + description: "`fileUpload` only." + maxSizeInMB: + type: number + description: "`fileUpload` only." + allowedFileExtensions: + type: array + items: + type: string + description: "`fileUpload` only." + calUserName: + type: string + description: "`cal` only." + calHost: + type: string + description: "`cal` only." + rows: + type: array + description: Matrix rows. + items: + $ref: "#/components/schemas/SurveyChoice" + columns: + type: array + description: Matrix columns. + items: + $ref: "#/components/schemas/SurveyChoice" + addressLine1: + $ref: "#/components/schemas/SurveyToggleInputConfig" + addressLine2: + $ref: "#/components/schemas/SurveyToggleInputConfig" + city: + $ref: "#/components/schemas/SurveyToggleInputConfig" + state: + $ref: "#/components/schemas/SurveyToggleInputConfig" + zip: + $ref: "#/components/schemas/SurveyToggleInputConfig" + country: + $ref: "#/components/schemas/SurveyToggleInputConfig" + firstName: + $ref: "#/components/schemas/SurveyToggleInputConfig" + lastName: + $ref: "#/components/schemas/SurveyToggleInputConfig" + email: + $ref: "#/components/schemas/SurveyToggleInputConfig" + phone: + $ref: "#/components/schemas/SurveyToggleInputConfig" + company: + $ref: "#/components/schemas/SurveyToggleInputConfig" + validation: + $ref: "#/components/schemas/SurveyValidation" + additionalProperties: true + SurveyChoice: + type: object + required: [id, label] + properties: + id: + type: string + description: Stable choice id. + label: + $ref: "#/components/schemas/TranslatableText" + additionalProperties: false + SurveyPictureChoice: + type: object + required: [id, imageUrl] + properties: + id: + type: string + description: Stable picture choice id. + imageUrl: + type: string + additionalProperties: false + SurveyToggleInputConfig: + type: object + description: Field config for address and contact info elements. + required: [show, required, placeholder] + properties: + show: + type: boolean + required: + type: boolean + placeholder: + $ref: "#/components/schemas/TranslatableText" + additionalProperties: false + SurveyValidation: + type: object + description: Optional element-level validation rules. + required: [rules] + properties: + logic: + type: string + enum: [and, or] + default: and + rules: + type: array + items: + $ref: "#/components/schemas/SurveyValidationRule" + additionalProperties: false + SurveyValidationRule: + type: object + required: [id, type, params] + properties: + id: + type: string + type: + type: string + enum: + - minLength + - maxLength + - pattern + - email + - url + - phone + - equals + - doesNotEqual + - contains + - doesNotContain + - minValue + - maxValue + - isGreaterThan + - isLessThan + - minSelections + - maxSelections + - minRanked + - rankAll + - minRowsAnswered + - answerAllRows + - isLaterThan + - isEarlierThan + - isBetween + - isNotBetween + - fileExtensionIs + - fileExtensionIsNot + params: + type: object + additionalProperties: true + field: + type: string + enum: + [ + addressLine1, + addressLine2, + city, + state, + zip, + country, + firstName, + lastName, + email, + phone, + company, + ] + additionalProperties: false + SurveyBlockLogic: + type: object + description: Conditional logic rule evaluated at block level. + required: [id, conditions, actions] + properties: + id: + type: string + format: cuid2 + conditions: + $ref: "#/components/schemas/SurveyConditionGroup" + actions: + type: array + items: + $ref: "#/components/schemas/SurveyLogicAction" + additionalProperties: false + SurveyConditionGroup: + type: object + required: [id, connector, conditions] + properties: + id: + type: string + format: cuid2 + connector: + type: string + enum: [and, or] + conditions: + type: array + items: + oneOf: + - $ref: "#/components/schemas/SurveyCondition" + - $ref: "#/components/schemas/SurveyConditionGroup" + additionalProperties: false + SurveyCondition: + type: object + description: | + Single condition. Operators such as `isSubmitted`, `isSkipped`, `isClicked`, `isAccepted`, + `isBooked`, `isSet`, and `isEmpty` do not use `rightOperand`; comparison operators do. + required: [id, leftOperand, operator] + properties: + id: + type: string + format: cuid2 + leftOperand: + $ref: "#/components/schemas/SurveyDynamicReference" + operator: + type: string + enum: + - equals + - doesNotEqual + - contains + - doesNotContain + - startsWith + - doesNotStartWith + - endsWith + - doesNotEndWith + - isSubmitted + - isSkipped + - isGreaterThan + - isLessThan + - isGreaterThanOrEqual + - isLessThanOrEqual + - equalsOneOf + - includesAllOf + - includesOneOf + - doesNotIncludeOneOf + - doesNotIncludeAllOf + - isClicked + - isNotClicked + - isAccepted + - isBefore + - isAfter + - isBooked + - isPartiallySubmitted + - isCompletelySubmitted + - isSet + - isNotSet + - isEmpty + - isNotEmpty + - isAnyOf + rightOperand: + $ref: "#/components/schemas/SurveyLogicOperand" + additionalProperties: false + SurveyLogicOperand: + oneOf: + - type: object + required: [type, value] + properties: + type: + type: string + enum: [static] + value: + oneOf: + - type: string + - type: number + - type: array + items: + type: string + additionalProperties: false + - $ref: "#/components/schemas/SurveyDynamicReference" + SurveyDynamicReference: + type: object + description: Dynamic reference to another value in the survey document. + required: [type, value] + properties: + type: + type: string + enum: [element, variable, hiddenField] + value: + type: string + description: Element id, variable id, or hidden field id depending on `type`. + meta: + type: object + additionalProperties: + type: string + additionalProperties: false + SurveyLogicAction: + oneOf: + - $ref: "#/components/schemas/SurveyCalculateAction" + - $ref: "#/components/schemas/SurveyRequireAnswerAction" + - $ref: "#/components/schemas/SurveyJumpToBlockAction" + description: | + Logic action. Keep referenced ids stable: `calculate.variableId` points to a variable id, + `requireAnswer.target` points to an element id, and `jumpToBlock.target` points to a block id + or ending id. + SurveyCalculateAction: + type: object + description: Updates a survey variable when the logic rule matches. + required: [id, objective, variableId, operator, value] + properties: + id: + type: string + format: cuid2 + objective: + type: string + enum: [calculate] + variableId: + type: string + format: cuid2 + description: Variable id for `calculate`. + operator: + type: string + enum: [assign, concat, add, subtract, multiply, divide] + value: + $ref: "#/components/schemas/SurveyLogicOperand" + additionalProperties: false + SurveyRequireAnswerAction: + type: object + description: Requires an element/question to be answered before continuing. + required: [id, objective, target] + properties: + id: + type: string + format: cuid2 + objective: + type: string + enum: [requireAnswer] + target: + type: string + description: Target element id. + additionalProperties: false + SurveyJumpToBlockAction: + type: object + description: Jumps to another block or ending when the logic rule matches. + required: [id, objective, target] + properties: + id: + type: string + format: cuid2 + objective: + type: string + enum: [jumpToBlock] + target: + type: string + format: cuid2 + description: Target block id or ending id. + additionalProperties: false + SurveyResource: + type: object + required: + - id + - workspaceId + - createdAt + - updatedAt + - name + - type + - status + - metadata + - defaultLanguage + - languages + - welcomeCard + - blocks + - endings + - hiddenFields + - variables + properties: + id: { type: string } + workspaceId: { type: string } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + name: { type: string } + type: { type: string, enum: [link, app, website, web] } + status: + type: string + enum: [draft, inProgress, paused, completed] + metadata: + type: object + nullable: true + additionalProperties: true + defaultLanguage: + type: string + description: Emitted language code/tag for the survey default language. The internal `default` translation key is never exposed. + languages: + type: array + items: + $ref: "#/components/schemas/SurveyLanguage" + welcomeCard: + $ref: "#/components/schemas/SurveyWelcomeCard" + blocks: + type: array + items: + $ref: "#/components/schemas/SurveyBlock" + endings: + type: array + items: + $ref: "#/components/schemas/SurveyEnding" + hiddenFields: + $ref: "#/components/schemas/SurveyHiddenFields" + variables: + type: array + items: + $ref: "#/components/schemas/SurveyVariable" Problem: type: object description: RFC 9457 Problem Details for HTTP APIs (`application/problem+json`). Responses typically include a machine-readable `code` field alongside `title`, `status`, `detail`, and `requestId`. @@ -335,3 +1179,6 @@ components: properties: name: { type: string } reason: { type: string } + identifier: + type: string + description: Optional normalized identifier related to the invalid value, such as a normalized language code. diff --git a/docs/unify-feedback/feedback-sources.mdx b/docs/unify-feedback/feedback-sources.mdx index 721f15996a74..8f5b9b595763 100644 --- a/docs/unify-feedback/feedback-sources.mdx +++ b/docs/unify-feedback/feedback-sources.mdx @@ -16,7 +16,7 @@ Pipe responses from a Formbricks survey directly into a Feedback Directory. Pick ### 2. CSV Import -Upload a CSV (up to **2 MB** and **1,000 rows**) and Formbricks auto-suggests a column mapping based on common header names (`timestamp`, `response_id`, `rating`, `feedback_text`, ...). Required columns: `submission_id`, `field_id`, `field_type`, and the feedback value. +Upload a CSV (up to **2 MB** and **1,000 rows**) and Formbricks auto-suggests a column mapping based on common header names (`timestamp`, `response_id`, `rating`, `feedback_text`, ...). Required columns: `submission_id`, `field_id`, `field_type`, and the feedback value. Re-uploading a CSV with the same `submission_id` updates existing records instead of creating duplicates.