mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 19:39:00 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 892c4e3b5f | |||
| e6f347aa07 | |||
| 367bc23dd4 | |||
| a1a11b2bb8 | |||
| 0653c6a59f | |||
| b6d793e109 | |||
| 439dd0b44e | |||
| 2556f5e15d | |||
| cc0eec3bf0 | |||
| 4b009a8eb4 | |||
| 2aaddf7306 | |||
| fb5d6145d0 | |||
| 59310bac93 |
+22
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle="" />
|
||||
<div className="flex h-9 animate-pulse gap-2">
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
<SkeletonLoader type="summary" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SkeletonLoader } from "@/modules/ui/components/skeleton-loader";
|
||||
|
||||
const Loading = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.responses")} />
|
||||
<div className="flex h-9 animate-pulse gap-1.5">
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
<div className="h-9 w-36 rounded-full bg-slate-200" />
|
||||
</div>
|
||||
<SkeletonLoader type="responseTable" />
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default Loading;
|
||||
+3
-1
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
|
||||
label={t("environments.surveys.summary.time_to_complete")}
|
||||
percentage={null}
|
||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
||||
tooltipText={t("environments.surveys.summary.ttc_tooltip")}
|
||||
tooltipText={t("environments.surveys.summary.ttc_survey_tooltip", {
|
||||
defaultValue: "Average time to complete the survey.",
|
||||
})}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
|
||||
+59
-4
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
|
||||
});
|
||||
|
||||
test("calculates meta correctly", () => {
|
||||
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
|
||||
expect(meta.displayCount).toBe(10);
|
||||
expect(meta.totalResponses).toBe(3);
|
||||
expect(meta.startsPercentage).toBe(30);
|
||||
@@ -178,19 +178,74 @@ describe("getSurveySummaryMeta", () => {
|
||||
});
|
||||
|
||||
test("handles zero display count", () => {
|
||||
const meta = getSurveySummaryMeta(mockResponses, 0, mockQuotas);
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 0, mockQuotas);
|
||||
expect(meta.startsPercentage).toBe(0);
|
||||
expect(meta.completedPercentage).toBe(0);
|
||||
});
|
||||
|
||||
test("handles zero responses", () => {
|
||||
const meta = getSurveySummaryMeta([], 10, mockQuotas);
|
||||
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
|
||||
expect(meta.totalResponses).toBe(0);
|
||||
expect(meta.completedResponses).toBe(0);
|
||||
expect(meta.dropOffCount).toBe(0);
|
||||
expect(meta.dropOffPercentage).toBe(0);
|
||||
expect(meta.ttcAverage).toBe(0);
|
||||
});
|
||||
|
||||
test("uses block-level TTC to avoid multiplying by number of elements", () => {
|
||||
const surveyWithOneBlockThreeElements: TSurvey = {
|
||||
...mockBaseSurvey,
|
||||
blocks: [
|
||||
{
|
||||
id: "block1",
|
||||
name: "Block 1",
|
||||
elements: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q2" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
{
|
||||
id: "q3",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q3" },
|
||||
required: false,
|
||||
inputType: "text",
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
] as TSurveyElement[],
|
||||
},
|
||||
],
|
||||
questions: [],
|
||||
};
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { q1: "a", q2: "b", q3: "c" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: "en",
|
||||
ttc: { q1: 5000, q2: 5000, q3: 4800, _total: 14800 },
|
||||
finished: true,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const meta = getSurveySummaryMeta(surveyWithOneBlockThreeElements, responses, 1, mockQuotas);
|
||||
expect(meta.ttcAverage).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSurveySummaryDropOff", () => {
|
||||
@@ -274,7 +329,7 @@ describe("getSurveySummaryDropOff", () => {
|
||||
expect(dropOff[1].impressions).toBe(2);
|
||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
||||
expect(dropOff[1].dropOffPercentage).toBe(50); // (1/2)*100
|
||||
expect(dropOff[1].ttc).toBe(7.5); // avg of r1(5ms) and r2(10ms)
|
||||
expect(dropOff[1].ttc).toBe(10); // block-level TTC uses max block time per response
|
||||
});
|
||||
|
||||
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||
|
||||
+48
-9
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
|
||||
finished: boolean;
|
||||
}
|
||||
|
||||
const getElementIdToBlockIdMap = (survey: TSurvey): Record<string, string> => {
|
||||
return survey.blocks.reduce<Record<string, string>>((acc, block) => {
|
||||
block.elements.forEach((element) => {
|
||||
acc[element.id] = block.id;
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getBlockTimesForResponse = (
|
||||
response: TSurveySummaryResponse,
|
||||
survey: TSurvey
|
||||
): Record<string, number> => {
|
||||
return survey.blocks.reduce<Record<string, number>>((acc, block) => {
|
||||
const maxElementTtc = block.elements.reduce((maxTtc, element) => {
|
||||
const elementTtc = response.ttc?.[element.id] ?? 0;
|
||||
return Math.max(maxTtc, elementTtc);
|
||||
}, 0);
|
||||
|
||||
acc[block.id] = maxElementTtc;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getSurveySummaryMeta = (
|
||||
survey: TSurvey,
|
||||
responses: TSurveySummaryResponse[],
|
||||
displayCount: number,
|
||||
quotas: TSurveySummary["quotas"]
|
||||
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
|
||||
|
||||
let ttcResponseCount = 0;
|
||||
const ttcSum = responses.reduce((acc, response) => {
|
||||
if (response.ttc?._total) {
|
||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
||||
const responseBlockTtcTotal = Object.values(blockTimes).reduce((sum, ttc) => sum + ttc, 0);
|
||||
|
||||
// Fallback to _total for malformed surveys with no block mappings.
|
||||
const responseTtcTotal = responseBlockTtcTotal > 0 ? responseBlockTtcTotal : (response.ttc?._total ?? 0);
|
||||
|
||||
if (responseTtcTotal > 0) {
|
||||
ttcResponseCount++;
|
||||
return acc + response.ttc._total;
|
||||
return acc + responseTtcTotal;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
|
||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
||||
let impressionsArr = new Array(elements.length).fill(0) as number[];
|
||||
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
|
||||
|
||||
responses.forEach((response) => {
|
||||
// Calculate total time-to-completion per element
|
||||
const blockTimes = getBlockTimesForResponse(response, survey);
|
||||
Object.keys(totalTtc).forEach((elementId) => {
|
||||
if (response.ttc && response.ttc[elementId]) {
|
||||
totalTtc[elementId] += response.ttc[elementId];
|
||||
const blockId = elementIdToBlockId[elementId];
|
||||
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
|
||||
if (blockTtc > 0) {
|
||||
totalTtc[elementId] += blockTtc;
|
||||
responseCounts[elementId]++;
|
||||
}
|
||||
});
|
||||
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
|
||||
]);
|
||||
|
||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
||||
const [meta, elementSummary] = await Promise.all([
|
||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
||||
getElementSummary(survey, elements, responses, dropOff),
|
||||
]);
|
||||
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
|
||||
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
|
||||
|
||||
return {
|
||||
meta,
|
||||
@@ -1061,7 +1094,9 @@ export const getResponsesForSummary = reactCache(
|
||||
const transformedResponses: TSurveySummaryResponse[] = await Promise.all(
|
||||
responses.map((responsePrisma) => {
|
||||
return {
|
||||
...responsePrisma,
|
||||
id: responsePrisma.id,
|
||||
data: (responsePrisma.data ?? {}) as TResponseData,
|
||||
updatedAt: responsePrisma.updatedAt,
|
||||
contact: responsePrisma.contact
|
||||
? {
|
||||
id: responsePrisma.contact.id as string,
|
||||
@@ -1070,6 +1105,10 @@ export const getResponsesForSummary = reactCache(
|
||||
)?.value as string,
|
||||
}
|
||||
: null,
|
||||
contactAttributes: (responsePrisma.contactAttributes ?? {}) as TResponseContactAttributes,
|
||||
language: responsePrisma.language,
|
||||
ttc: (responsePrisma.ttc ?? {}) as TResponseTtc,
|
||||
finished: responsePrisma.finished,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { captureSurveyResponsePostHogEvent } from "./posthog";
|
||||
|
||||
vi.mock("@/lib/posthog", () => ({
|
||||
capturePostHogEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("captureSurveyResponsePostHogEvent", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const makeParams = (responseCount: number) => ({
|
||||
organizationId: "org-1",
|
||||
surveyId: "survey-1",
|
||||
surveyType: "link",
|
||||
environmentId: "env-1",
|
||||
responseCount,
|
||||
});
|
||||
|
||||
test("fires on 1st response with milestone 'first'", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
captureSurveyResponsePostHogEvent(makeParams(1));
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith("org-1", "survey_response_received", {
|
||||
survey_id: "survey-1",
|
||||
survey_type: "link",
|
||||
organization_id: "org-1",
|
||||
environment_id: "env-1",
|
||||
response_count: 1,
|
||||
is_first_response: true,
|
||||
milestone: "first",
|
||||
});
|
||||
});
|
||||
|
||||
test("fires on every 100th response", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [100, 200, 300, 500, 1000, 5000]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
test("does NOT fire for 2nd through 99th responses", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [2, 5, 10, 50, 99]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("does NOT fire for non-100th counts above 100", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
for (const count of [101, 150, 250, 499, 501]) {
|
||||
captureSurveyResponsePostHogEvent(makeParams(count));
|
||||
}
|
||||
|
||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sets milestone to count string for non-first milestones", async () => {
|
||||
const { capturePostHogEvent } = await import("@/lib/posthog");
|
||||
|
||||
captureSurveyResponsePostHogEvent(makeParams(200));
|
||||
|
||||
expect(capturePostHogEvent).toHaveBeenCalledWith(
|
||||
"org-1",
|
||||
"survey_response_received",
|
||||
expect.objectContaining({
|
||||
is_first_response: false,
|
||||
milestone: "200",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
|
||||
interface SurveyResponsePostHogEventParams {
|
||||
organizationId: string;
|
||||
surveyId: string;
|
||||
surveyType: string;
|
||||
environmentId: string;
|
||||
responseCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures a PostHog event for survey responses at milestones:
|
||||
* 1st response, then every 100th (100, 200, 300, ...).
|
||||
*/
|
||||
export const captureSurveyResponsePostHogEvent = ({
|
||||
organizationId,
|
||||
surveyId,
|
||||
surveyType,
|
||||
environmentId,
|
||||
responseCount,
|
||||
}: SurveyResponsePostHogEventParams): void => {
|
||||
if (responseCount !== 1 && responseCount % 100 !== 0) return;
|
||||
|
||||
capturePostHogEvent(organizationId, "survey_response_received", {
|
||||
survey_id: surveyId,
|
||||
survey_type: surveyType,
|
||||
organization_id: organizationId,
|
||||
environment_id: environmentId,
|
||||
response_count: responseCount,
|
||||
is_first_response: responseCount === 1,
|
||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
||||
});
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -9,12 +8,10 @@ import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { convertDatesInObject } from "@/lib/time";
|
||||
@@ -27,6 +24,7 @@ import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
const requestHeaders = await headers();
|
||||
@@ -302,25 +300,16 @@ export const POST = async (request: Request) => {
|
||||
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
|
||||
});
|
||||
|
||||
// Sampled PostHog tracking: first response + every 100th
|
||||
if (POSTHOG_KEY) {
|
||||
const responseCount = await cache.withCache(
|
||||
() => getResponseCountBySurveyId(surveyId),
|
||||
createCacheKey.response.countBySurveyId(surveyId),
|
||||
60 * 1000
|
||||
);
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
|
||||
if (responseCount === 1 || responseCount % 100 === 0) {
|
||||
capturePostHogEvent(organization.id, "survey_response_received", {
|
||||
survey_id: surveyId,
|
||||
survey_type: survey.type,
|
||||
organization_id: organization.id,
|
||||
environment_id: environmentId,
|
||||
response_count: responseCount,
|
||||
is_first_response: responseCount === 1,
|
||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
||||
});
|
||||
}
|
||||
captureSurveyResponsePostHogEvent({
|
||||
organizationId: organization.id,
|
||||
surveyId,
|
||||
surveyType: survey.type,
|
||||
environmentId,
|
||||
responseCount,
|
||||
});
|
||||
}
|
||||
|
||||
// Send telemetry events
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { google } from "googleapis";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
@@ -10,6 +11,8 @@ import {
|
||||
} from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
|
||||
|
||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(session.user.id, "integration_connected", {
|
||||
integration_type: "googleSheets",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for googleSheets");
|
||||
}
|
||||
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||
);
|
||||
|
||||
@@ -70,6 +70,7 @@ const mockEnvironmentData = {
|
||||
displayOption: "displayOnce",
|
||||
hiddenFields: { enabled: false },
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
triggers: [],
|
||||
displayPercentage: null,
|
||||
delay: 0,
|
||||
@@ -122,6 +123,13 @@ describe("getEnvironmentStateData", () => {
|
||||
surveys: expect.any(Object),
|
||||
}),
|
||||
});
|
||||
|
||||
const prismaCall = vi.mocked(prisma.environment.findUnique).mock.calls[0][0];
|
||||
expect(prismaCall.select.surveys.select).toEqual(
|
||||
expect.objectContaining({
|
||||
isAutoProgressingEnabled: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when environment is not found", async () => {
|
||||
|
||||
@@ -121,6 +121,7 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
displayOption: true,
|
||||
hiddenFields: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
triggers: {
|
||||
select: {
|
||||
actionClass: {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
const getEmail = async (token: string) => {
|
||||
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
||||
@@ -86,6 +88,17 @@ export const GET = withV1ApiWrapper({
|
||||
},
|
||||
};
|
||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "airtable",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for airtable");
|
||||
}
|
||||
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
@@ -11,6 +12,8 @@ import {
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
|
||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "notion",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for notion");
|
||||
}
|
||||
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
TIntegrationSlackConfig,
|
||||
TIntegrationSlackConfigData,
|
||||
@@ -8,6 +9,8 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { capturePostHogEvent } from "@/lib/posthog";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req, authentication }) => {
|
||||
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
|
||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||
|
||||
if (result) {
|
||||
try {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
|
||||
capturePostHogEvent(authentication.user.id, "integration_connected", {
|
||||
integration_type: "slack",
|
||||
organization_id: organizationId,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ error: err }, "Failed to capture PostHog integration_connected event for slack");
|
||||
}
|
||||
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -175,10 +175,34 @@ describe("createResponse V2", () => {
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["surveyId", "singleUseId"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["displayId"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -129,6 +129,13 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
const target = (error.meta?.target as string[]) ?? [];
|
||||
if (target?.includes("singleUseId")) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { ZEnvironmentId } from "@formbricks/types/environment";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
@@ -177,6 +177,10 @@ const createResponseForRequest = async ({
|
||||
return responses.badRequestResponse(error.message, undefined, true);
|
||||
}
|
||||
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message, undefined, true);
|
||||
}
|
||||
|
||||
const response = getUnexpectedPublicErrorResponse();
|
||||
reportApiError({
|
||||
request,
|
||||
|
||||
@@ -25,6 +25,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
||||
reportApiError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEvent: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
@@ -321,4 +329,67 @@ describe("withV3ApiWrapper", () => {
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
expect(body.requestId).toBe("req-boom");
|
||||
});
|
||||
|
||||
test("reports handled non-ok responses and queues audit logs when configured", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const { reportApiError } = await import("@/app/lib/api/api-error-reporter");
|
||||
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
handler: async ({ auditLog }) => {
|
||||
if (auditLog) {
|
||||
auditLog.organizationId = "org_1";
|
||||
auditLog.targetId = "survey_1";
|
||||
}
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
},
|
||||
});
|
||||
|
||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys/survey_1"), {} as never);
|
||||
|
||||
expect(response.status).toBe(204);
|
||||
expect(vi.mocked(reportApiError)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(queueAuditEvent)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: "survey_1",
|
||||
organizationId: "org_1",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("reports handler error responses through reportApiError", async () => {
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const { reportApiError } = await import("@/app/lib/api/api-error-reporter");
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
handler: async () => new Response(JSON.stringify({ error: true }), { status: 403 }),
|
||||
});
|
||||
|
||||
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(vi.mocked(reportApiError)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 403,
|
||||
apiVersion: "v3",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,14 @@ import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import type { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import {
|
||||
type InvalidParam,
|
||||
problemBadRequest,
|
||||
@@ -15,7 +19,7 @@ import {
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
} from "./response";
|
||||
import type { TV3Authentication } from "./types";
|
||||
import type { TV3AuditLog, TV3Authentication } from "./types";
|
||||
|
||||
type TV3Schema = z.ZodTypeAny;
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
@@ -41,6 +45,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
|
||||
parsedInput: TParsedInput;
|
||||
requestId: string;
|
||||
instance: string;
|
||||
auditLog?: TV3AuditLog;
|
||||
};
|
||||
|
||||
export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = unknown> = {
|
||||
@@ -48,6 +53,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
|
||||
schemas?: S;
|
||||
rateLimit?: boolean;
|
||||
customRateLimitConfig?: TRateLimitConfig;
|
||||
action?: TAuditAction;
|
||||
targetType?: TAuditTarget;
|
||||
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
|
||||
};
|
||||
|
||||
@@ -64,10 +71,22 @@ function getUnauthenticatedDetail(authMode: TV3AuthMode): string {
|
||||
}
|
||||
|
||||
function formatZodIssues(error: z.ZodError, fallbackName: "body" | "query" | "params"): InvalidParam[] {
|
||||
return error.issues.map((issue) => ({
|
||||
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
|
||||
reason: issue.message,
|
||||
}));
|
||||
return error.issues.flatMap((issue) => {
|
||||
if (issue.code === "unrecognized_keys" && issue.keys.length > 0) {
|
||||
const prefix = issue.path.length > 0 ? `${issue.path.join(".")}.` : "";
|
||||
return issue.keys.map((key) => ({
|
||||
name: `${prefix}${key}`,
|
||||
reason: "Unsupported field",
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: issue.path.length > 0 ? issue.path.join(".") : fallbackName,
|
||||
reason: issue.message,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function searchParamsToObject(searchParams: URLSearchParams): Record<string, string | string[]> {
|
||||
@@ -239,6 +258,56 @@ function ensureRequestIdHeader(response: Response, requestId: string): Response
|
||||
});
|
||||
}
|
||||
|
||||
function enrichV3AuditLog(authentication: TV3Authentication, auditLog?: TV3AuditLog): void {
|
||||
if (!authentication || !auditLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ("user" in authentication && authentication.user?.id) {
|
||||
auditLog.userId = authentication.user.id;
|
||||
auditLog.userType = "user";
|
||||
return;
|
||||
}
|
||||
|
||||
if ("apiKeyId" in authentication) {
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.userType = "api";
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
async function processV3Response(params: {
|
||||
response: Response;
|
||||
request: NextRequest;
|
||||
requestId: string;
|
||||
auditLog?: TV3AuditLog;
|
||||
error?: unknown;
|
||||
}): Promise<Response> {
|
||||
const responseWithRequestId = ensureRequestIdHeader(params.response, params.requestId);
|
||||
|
||||
if (params.auditLog) {
|
||||
params.auditLog.status = responseWithRequestId.ok ? "success" : "failure";
|
||||
if (!responseWithRequestId.ok) {
|
||||
params.auditLog.eventId = params.requestId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!responseWithRequestId.ok) {
|
||||
reportApiError({
|
||||
request: params.request,
|
||||
status: responseWithRequestId.status,
|
||||
error: params.error,
|
||||
apiVersion: "v3",
|
||||
});
|
||||
}
|
||||
|
||||
if (params.auditLog) {
|
||||
await queueAuditEvent(params.auditLog);
|
||||
}
|
||||
|
||||
return responseWithRequestId;
|
||||
}
|
||||
|
||||
async function authenticateV3RequestOrRespond(
|
||||
req: NextRequest,
|
||||
authMode: TV3AuthMode,
|
||||
@@ -296,11 +365,20 @@ async function applyV3RateLimitOrRespond(params: {
|
||||
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
|
||||
params: TWithV3ApiWrapperParams<S, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
|
||||
const {
|
||||
auth = "both",
|
||||
schemas,
|
||||
rateLimit = true,
|
||||
customRateLimitConfig,
|
||||
action,
|
||||
targetType,
|
||||
handler,
|
||||
} = params;
|
||||
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
|
||||
const instance = req.nextUrl.pathname;
|
||||
const auditLog = action && targetType ? buildAuditLogBaseObject(action, targetType, req.url) : undefined;
|
||||
const log = logger.withContext({
|
||||
requestId,
|
||||
method: req.method,
|
||||
@@ -311,13 +389,24 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
|
||||
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
|
||||
if (authResult.response) {
|
||||
log.warn({ statusCode: authResult.response.status }, "V3 API authentication failed");
|
||||
return authResult.response;
|
||||
return await processV3Response({
|
||||
response: authResult.response,
|
||||
request: req,
|
||||
requestId,
|
||||
auditLog,
|
||||
});
|
||||
}
|
||||
enrichV3AuditLog(authResult.authentication, auditLog);
|
||||
|
||||
const parsedInputResult = await parseV3Input(req, props, schemas, requestId, instance);
|
||||
if (!parsedInputResult.ok) {
|
||||
log.warn({ statusCode: parsedInputResult.response.status }, "V3 API request validation failed");
|
||||
return parsedInputResult.response;
|
||||
return await processV3Response({
|
||||
response: parsedInputResult.response,
|
||||
request: req,
|
||||
requestId,
|
||||
auditLog,
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitResponse = await applyV3RateLimitOrRespond({
|
||||
@@ -328,7 +417,12 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
|
||||
log,
|
||||
});
|
||||
if (rateLimitResponse) {
|
||||
return rateLimitResponse;
|
||||
return await processV3Response({
|
||||
response: rateLimitResponse,
|
||||
request: req,
|
||||
requestId,
|
||||
auditLog,
|
||||
});
|
||||
}
|
||||
|
||||
const response = await handler({
|
||||
@@ -338,12 +432,24 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
|
||||
parsedInput: parsedInputResult.parsedInput,
|
||||
requestId,
|
||||
instance,
|
||||
auditLog,
|
||||
});
|
||||
|
||||
return ensureRequestIdHeader(response, requestId);
|
||||
return await processV3Response({
|
||||
response,
|
||||
request: req,
|
||||
requestId,
|
||||
auditLog,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
return await processV3Response({
|
||||
response: problemInternalError(requestId, "An unexpected error occurred.", instance),
|
||||
request: req,
|
||||
requestId,
|
||||
auditLog,
|
||||
error,
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,11 @@ import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/err
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromProjectId } from "@/lib/utils/helper";
|
||||
import { getEnvironment } from "@/lib/utils/services";
|
||||
import { requireSessionWorkspaceAccess, requireV3WorkspaceAccess } from "./auth";
|
||||
import { requireSessionWorkspaceAccess, requireV3SurveyAccess, requireV3WorkspaceAccess } from "./auth";
|
||||
|
||||
const { mockGetSurvey } = vi.hoisted(() => ({
|
||||
mockGetSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
@@ -27,6 +31,10 @@ vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
|
||||
checkAuthorizationUpdated: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/survey", () => ({
|
||||
getSurvey: mockGetSurvey,
|
||||
}));
|
||||
|
||||
const requestId = "req-123";
|
||||
|
||||
describe("requireSessionWorkspaceAccess", () => {
|
||||
@@ -272,3 +280,84 @@ describe("requireV3WorkspaceAccess", () => {
|
||||
expect((r as Response).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requireV3SurveyAccess", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getEnvironment).mockResolvedValue({
|
||||
id: "env_survey",
|
||||
projectId: "proj_survey",
|
||||
} as any);
|
||||
vi.mocked(getOrganizationIdFromProjectId).mockResolvedValue("org_survey");
|
||||
});
|
||||
|
||||
test("returns 404 when the survey does not exist", async () => {
|
||||
mockGetSurvey.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await requireV3SurveyAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"survey_missing",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(404);
|
||||
expect(getEnvironment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns survey context when the survey exists and the caller has access", async () => {
|
||||
const survey = {
|
||||
id: "survey_1",
|
||||
environmentId: "env_survey",
|
||||
};
|
||||
|
||||
mockGetSurvey.mockResolvedValueOnce(survey as any);
|
||||
vi.mocked(checkAuthorizationUpdated).mockResolvedValueOnce(undefined as any);
|
||||
|
||||
const result = await requireV3SurveyAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"survey_1",
|
||||
"readWrite",
|
||||
requestId
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
environmentId: "env_survey",
|
||||
projectId: "proj_survey",
|
||||
organizationId: "org_survey",
|
||||
survey,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 403 when the survey exists but the caller lacks access", async () => {
|
||||
mockGetSurvey.mockResolvedValueOnce({
|
||||
id: "survey_forbidden",
|
||||
environmentId: "env_survey",
|
||||
} as any);
|
||||
vi.mocked(checkAuthorizationUpdated).mockRejectedValueOnce(new AuthorizationError("Forbidden"));
|
||||
|
||||
const result = await requireV3SurveyAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"survey_forbidden",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(403);
|
||||
});
|
||||
|
||||
test("returns 404 when loading the survey throws ResourceNotFoundError", async () => {
|
||||
mockGetSurvey.mockRejectedValueOnce(new ResourceNotFoundError("Survey", "survey_err"));
|
||||
|
||||
const result = await requireV3SurveyAccess(
|
||||
{ user: { id: "user_1" }, expires: "" } as any,
|
||||
"survey_err",
|
||||
"read",
|
||||
requestId
|
||||
);
|
||||
|
||||
expect(result).toBeInstanceOf(Response);
|
||||
expect((result as Response).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
+152
-54
@@ -5,9 +5,11 @@ import { ApiKeyPermission } from "@prisma/client";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import type { TTeamPermission } from "@/modules/ee/teams/project-teams/types/team";
|
||||
import { problemForbidden, problemUnauthorized } from "./response";
|
||||
import { getSurvey } from "@/modules/survey/lib/survey";
|
||||
import { problemForbidden, problemNotFound, problemUnauthorized } from "./response";
|
||||
import type { TV3Authentication } from "./types";
|
||||
import { type V3WorkspaceContext, resolveV3WorkspaceContext } from "./workspace-context";
|
||||
|
||||
@@ -27,6 +29,97 @@ function apiKeyPermissionAllows(permission: ApiKeyPermission, minPermission: TTe
|
||||
return grantedRank >= requiredRank;
|
||||
}
|
||||
|
||||
export type V3SurveyContext = V3WorkspaceContext & {
|
||||
survey: TSurvey;
|
||||
};
|
||||
|
||||
async function authorizeSessionWorkspaceContext(
|
||||
authentication: TV3Authentication,
|
||||
context: V3WorkspaceContext,
|
||||
minPermission: TTeamPermission,
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3WorkspaceContext> {
|
||||
if (!("user" in authentication) || !authentication.user?.id) {
|
||||
return problemUnauthorized(requestId, "Session required", instance);
|
||||
}
|
||||
|
||||
const log = logger.withContext({ requestId, workspaceId: context.environmentId });
|
||||
|
||||
try {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: authentication.user.id,
|
||||
organizationId: context.organizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", projectId: context.projectId, minPermission },
|
||||
],
|
||||
});
|
||||
|
||||
return context;
|
||||
} catch (err) {
|
||||
if (err instanceof AuthorizationError) {
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, "Forbidden");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function authorizeApiKeyWorkspaceContext(
|
||||
authentication: TAuthenticationApiKey,
|
||||
context: V3WorkspaceContext,
|
||||
minPermission: TTeamPermission,
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Response | V3WorkspaceContext {
|
||||
const log = logger.withContext({
|
||||
requestId,
|
||||
workspaceId: context.environmentId,
|
||||
apiKeyId: authentication.apiKeyId,
|
||||
});
|
||||
|
||||
const permission = authentication.environmentPermissions.find(
|
||||
(environmentPermission) => environmentPermission.environmentId === context.environmentId
|
||||
);
|
||||
|
||||
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
|
||||
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
async function authorizeV3WorkspaceContext(
|
||||
authentication: TV3Authentication,
|
||||
context: V3WorkspaceContext,
|
||||
minPermission: TTeamPermission,
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3WorkspaceContext> {
|
||||
if (!authentication) {
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
|
||||
if ("user" in authentication && authentication.user?.id) {
|
||||
return await authorizeSessionWorkspaceContext(
|
||||
authentication,
|
||||
context,
|
||||
minPermission,
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
}
|
||||
|
||||
if ("apiKeyId" in authentication && Array.isArray(authentication.environmentPermissions)) {
|
||||
return authorizeApiKeyWorkspaceContext(authentication, context, minPermission, requestId, instance);
|
||||
}
|
||||
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require session and workspace access. workspaceId is resolved via the V3 workspace-context layer.
|
||||
* Returns a Response (401 or 403) on failure, or the resolved workspace context on success so callers
|
||||
@@ -40,7 +133,6 @@ export async function requireSessionWorkspaceAccess(
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3WorkspaceContext> {
|
||||
// --- Session checks ---
|
||||
if (!authentication) {
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
@@ -48,28 +140,19 @@ export async function requireSessionWorkspaceAccess(
|
||||
return problemUnauthorized(requestId, "Session required", instance);
|
||||
}
|
||||
|
||||
const userId = authentication.user.id;
|
||||
const log = logger.withContext({ requestId, workspaceId });
|
||||
|
||||
try {
|
||||
// Resolve workspaceId → environmentId, projectId, organizationId (single place to change when Workspace exists).
|
||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
||||
|
||||
// Org + project-team access; we use internal IDs from context.
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId: context.organizationId,
|
||||
access: [
|
||||
{ type: "organization", roles: ["owner", "manager"] },
|
||||
{ type: "projectTeam", projectId: context.projectId, minPermission },
|
||||
],
|
||||
});
|
||||
|
||||
return context;
|
||||
return await authorizeSessionWorkspaceContext(
|
||||
authentication,
|
||||
context,
|
||||
minPermission,
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ResourceNotFoundError || err instanceof AuthorizationError) {
|
||||
const message = err instanceof ResourceNotFoundError ? "Workspace not found" : "Forbidden";
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, message);
|
||||
const log = logger.withContext({ requestId, workspaceId });
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
log.warn({ statusCode: 403, errorCode: err.name }, "Workspace not found");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
throw err;
|
||||
@@ -84,39 +167,54 @@ export async function requireV3WorkspaceAccess(
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3WorkspaceContext> {
|
||||
if (!authentication) {
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
}
|
||||
|
||||
if ("user" in authentication && authentication.user?.id) {
|
||||
return requireSessionWorkspaceAccess(authentication, workspaceId, minPermission, requestId, instance);
|
||||
}
|
||||
|
||||
const keyAuth = authentication as TAuthenticationApiKey;
|
||||
if (keyAuth.apiKeyId && Array.isArray(keyAuth.environmentPermissions)) {
|
||||
const log = logger.withContext({ requestId, workspaceId, apiKeyId: keyAuth.apiKeyId });
|
||||
|
||||
try {
|
||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
||||
const permission = keyAuth.environmentPermissions.find(
|
||||
(environmentPermission) => environmentPermission.environmentId === context.environmentId
|
||||
);
|
||||
|
||||
if (!permission || !apiKeyPermissionAllows(permission.permission, minPermission)) {
|
||||
log.warn({ statusCode: 403 }, "API key not allowed for workspace");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
return context;
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
throw error;
|
||||
try {
|
||||
const context = await resolveV3WorkspaceContext(workspaceId);
|
||||
return await authorizeV3WorkspaceContext(authentication, context, minPermission, requestId, instance);
|
||||
} catch (error) {
|
||||
const log = logger.withContext({ requestId, workspaceId });
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
log.warn({ statusCode: 403, errorCode: error.name }, "Workspace not found");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
}
|
||||
|
||||
return problemUnauthorized(requestId, "Not authenticated", instance);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireV3SurveyAccess(
|
||||
authentication: TV3Authentication,
|
||||
surveyId: string,
|
||||
minPermission: TTeamPermission,
|
||||
requestId: string,
|
||||
instance?: string
|
||||
): Promise<Response | V3SurveyContext> {
|
||||
try {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
return problemNotFound(requestId, "Survey", surveyId, instance);
|
||||
}
|
||||
|
||||
const workspaceAccess = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
survey.environmentId,
|
||||
minPermission,
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
|
||||
if (workspaceAccess instanceof Response) {
|
||||
return workspaceAccess;
|
||||
}
|
||||
|
||||
return {
|
||||
...workspaceAccess,
|
||||
survey,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return problemNotFound(requestId, "Survey", surveyId, instance);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
createdResponse,
|
||||
noContentResponse,
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
@@ -7,6 +9,7 @@ import {
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
successListResponse,
|
||||
successResponse,
|
||||
} from "./response";
|
||||
|
||||
describe("v3 problem responses", () => {
|
||||
@@ -70,6 +73,37 @@ describe("v3 problem responses", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("item success responses", () => {
|
||||
test("successResponse sets request id and omits meta by default", async () => {
|
||||
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-success");
|
||||
expect(await res.json()).toEqual({
|
||||
data: { id: "survey_1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("createdResponse sets location header", async () => {
|
||||
const res = createdResponse(
|
||||
{ id: "survey_1" },
|
||||
{ requestId: "req-created", location: "/api/v3/surveys/survey_1" }
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.headers.get("Location")).toBe("/api/v3/surveys/survey_1");
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-created");
|
||||
expect(await res.json()).toEqual({
|
||||
data: { id: "survey_1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("noContentResponse keeps request id and no-store cache", () => {
|
||||
const res = noContentResponse({ requestId: "req-no-content" });
|
||||
expect(res.status).toBe(204);
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-no-content");
|
||||
expect(res.headers.get("Cache-Control")).toContain("no-store");
|
||||
});
|
||||
});
|
||||
|
||||
describe("successListResponse", () => {
|
||||
test("sets X-Request-Id and default cache", async () => {
|
||||
const res = successListResponse(
|
||||
|
||||
@@ -59,6 +59,46 @@ function problemResponse(
|
||||
return Response.json(body, { status, headers });
|
||||
}
|
||||
|
||||
function buildSuccessHeaders(options?: {
|
||||
requestId?: string;
|
||||
cache?: string;
|
||||
headers?: Record<string, string>;
|
||||
}): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
if (options?.requestId) {
|
||||
headers["X-Request-Id"] = options.requestId;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
function successJsonResponse<T, TMeta extends Record<string, unknown> | undefined>(
|
||||
status: number,
|
||||
data: T,
|
||||
options?: {
|
||||
requestId?: string;
|
||||
cache?: string;
|
||||
meta?: TMeta;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
): Response {
|
||||
return Response.json(
|
||||
{
|
||||
data,
|
||||
...(options?.meta ? { meta: options.meta } : {}),
|
||||
},
|
||||
{
|
||||
status,
|
||||
headers: buildSuccessHeaders(options),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function problemBadRequest(
|
||||
requestId: string,
|
||||
detail: string,
|
||||
@@ -133,17 +173,46 @@ export function problemTooManyRequests(requestId: string, detail: string, retryA
|
||||
});
|
||||
}
|
||||
|
||||
export function successResponse<T, TMeta extends Record<string, unknown> | undefined = undefined>(
|
||||
data: T,
|
||||
options?: { requestId?: string; cache?: string; meta?: TMeta }
|
||||
): Response {
|
||||
return successJsonResponse(200, data, options);
|
||||
}
|
||||
|
||||
export function createdResponse<T, TMeta extends Record<string, unknown> | undefined = undefined>(
|
||||
data: T,
|
||||
options?: {
|
||||
requestId?: string;
|
||||
cache?: string;
|
||||
meta?: TMeta;
|
||||
location?: string;
|
||||
}
|
||||
): Response {
|
||||
return successJsonResponse(201, data, {
|
||||
...options,
|
||||
headers: options?.location ? { Location: options.location } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function noContentResponse(options?: { requestId?: string; cache?: string }): Response {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
|
||||
...(options?.requestId ? { "X-Request-Id": options.requestId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function successListResponse<T, TMeta extends Record<string, unknown>>(
|
||||
data: T[],
|
||||
meta: TMeta,
|
||||
options?: { requestId?: string; cache?: string }
|
||||
): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
|
||||
};
|
||||
if (options?.requestId) {
|
||||
headers["X-Request-Id"] = options.requestId;
|
||||
}
|
||||
return Response.json({ data, meta }, { status: 200, headers });
|
||||
return successJsonResponse(200, data, {
|
||||
requestId: options?.requestId,
|
||||
cache: options?.cache,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
|
||||
export type TV3Authentication = TAuthenticationApiKey | Session | null;
|
||||
export type TV3AuditLog = TApiAuditLog;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
import { requireV3SurveyAccess } from "@/app/api/v3/lib/auth";
|
||||
import { updateSurvey } from "@/lib/survey/service";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
|
||||
import { deleteSurvey } from "@/modules/survey/list/lib/survey";
|
||||
import { buildV3SurveyCreateInput, buildV3SurveyPreview } from "../adapters";
|
||||
import { ZV3SurveyCreateBody } from "../schemas";
|
||||
import { DELETE, GET, PATCH } 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<typeof import("@/app/api/v1/auth")>();
|
||||
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<typeof import("@/lib/constants")>();
|
||||
return { ...actual, AUDIT_LOG_ENABLED: false };
|
||||
});
|
||||
|
||||
vi.mock("@/app/api/v3/lib/auth", () => ({
|
||||
requireV3SurveyAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
updateSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
|
||||
return {
|
||||
...actual,
|
||||
deleteSurvey: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/survey/editor/lib/check-external-urls-permission", () => ({
|
||||
checkExternalUrlsPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/ee/audit-logs/lib/handler")>();
|
||||
return {
|
||||
...actual,
|
||||
queueAuditEvent: 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 workspaceId = createId();
|
||||
const surveyId = createId();
|
||||
const requestId = "req-item";
|
||||
const baseCreateBody = {
|
||||
workspaceId,
|
||||
name: "Item API Survey",
|
||||
blocks: [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Intro",
|
||||
elements: [
|
||||
{
|
||||
id: "question_1",
|
||||
type: "openText",
|
||||
headline: { default: "What should we improve?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const parsedCreateBody = ZV3SurveyCreateBody.parse(baseCreateBody);
|
||||
|
||||
function buildSurveyFixture(name = "Item API Survey") {
|
||||
return buildV3SurveyPreview(
|
||||
workspaceId,
|
||||
buildV3SurveyCreateInput(
|
||||
{
|
||||
...parsedCreateBody,
|
||||
name,
|
||||
},
|
||||
"user_1"
|
||||
),
|
||||
surveyId
|
||||
);
|
||||
}
|
||||
|
||||
function createRequest(method: string, url: string, body?: unknown): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": requestId,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("/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(requireV3SurveyAccess).mockResolvedValue({
|
||||
environmentId: workspaceId,
|
||||
projectId: "proj_1",
|
||||
organizationId: "org_1",
|
||||
survey: buildSurveyFixture(),
|
||||
} as any);
|
||||
vi.mocked(updateSurvey).mockResolvedValue(buildSurveyFixture("Updated survey"));
|
||||
vi.mocked(deleteSurvey).mockResolvedValue(true);
|
||||
vi.mocked(checkExternalUrlsPermission).mockResolvedValue(undefined);
|
||||
vi.mocked(queueAuditEvent).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("GET returns the survey resource", async () => {
|
||||
const res = await GET(createRequest("GET", `http://localhost/api/v3/surveys/${surveyId}`), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as any);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.workspaceId).toBe(workspaceId);
|
||||
expect(body.data).not.toHaveProperty("environmentId");
|
||||
expect(requireV3SurveyAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: expect.any(Object) }),
|
||||
surveyId,
|
||||
"read",
|
||||
requestId,
|
||||
`/api/v3/surveys/${surveyId}`
|
||||
);
|
||||
});
|
||||
|
||||
test("GET returns route parameter validation errors", async () => {
|
||||
const res = await GET(createRequest("GET", "http://localhost/api/v3/surveys/not-valid"), {
|
||||
params: Promise.resolve({ surveyId: "not-valid" }),
|
||||
} as any);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(requireV3SurveyAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("PATCH updates the survey and returns 200", async () => {
|
||||
const res = await PATCH(
|
||||
createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {
|
||||
name: "Updated survey",
|
||||
}),
|
||||
{ params: Promise.resolve({ surveyId }) } as any
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireV3SurveyAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: expect.any(Object) }),
|
||||
surveyId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
`/api/v3/surveys/${surveyId}`
|
||||
);
|
||||
expect(checkExternalUrlsPermission).toHaveBeenCalled();
|
||||
expect(updateSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: surveyId, name: "Updated survey" })
|
||||
);
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "updated",
|
||||
targetType: "survey",
|
||||
targetId: surveyId,
|
||||
organizationId: "org_1",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("PATCH rejects immutable fields", async () => {
|
||||
const res = await PATCH(
|
||||
createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {
|
||||
workspaceId: createId(),
|
||||
}),
|
||||
{ params: Promise.resolve({ surveyId }) } as any
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.invalid_params).toContainEqual({
|
||||
name: "workspaceId",
|
||||
reason: "Unsupported field",
|
||||
});
|
||||
expect(updateSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("PATCH returns 400 for an empty body", async () => {
|
||||
const res = await PATCH(createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {}), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as any);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test("PATCH returns 403 when external url permission blocks the change", async () => {
|
||||
vi.mocked(checkExternalUrlsPermission).mockRejectedValueOnce(
|
||||
new OperationNotAllowedError("External URLs are not enabled")
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {
|
||||
name: "Blocked update",
|
||||
}),
|
||||
{ params: Promise.resolve({ surveyId }) } as any
|
||||
);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test("PATCH propagates a not found response from survey auth", async () => {
|
||||
vi.mocked(requireV3SurveyAccess).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
title: "Not Found",
|
||||
status: 404,
|
||||
detail: "Survey not found",
|
||||
requestId,
|
||||
}),
|
||||
{ status: 404, headers: { "Content-Type": "application/problem+json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const res = await PATCH(
|
||||
createRequest("PATCH", `http://localhost/api/v3/surveys/${surveyId}`, {
|
||||
name: "Missing survey",
|
||||
}),
|
||||
{ params: Promise.resolve({ surveyId }) } as any
|
||||
);
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(updateSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("DELETE removes the survey and returns 204", async () => {
|
||||
const res = await DELETE(createRequest("DELETE", `http://localhost/api/v3/surveys/${surveyId}`), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as any);
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
|
||||
expect(res.headers.get("X-Request-Id")).toBe(requestId);
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: surveyId,
|
||||
organizationId: "org_1",
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { requireV3SurveyAccess } from "@/app/api/v3/lib/auth";
|
||||
import {
|
||||
noContentResponse,
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
successResponse,
|
||||
} from "@/app/api/v3/lib/response";
|
||||
import { updateSurvey } from "@/lib/survey/service";
|
||||
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
|
||||
import { deleteSurvey } from "@/modules/survey/list/lib/survey";
|
||||
import { applyV3SurveyPatch } from "../adapters";
|
||||
import { ZV3SurveyPatchBody, ZV3SurveyRouteParams } from "../schemas";
|
||||
import { serializeV3SurveyResource } from "../serializers";
|
||||
|
||||
function handleSurveyMutationError(
|
||||
error: unknown,
|
||||
requestId: string,
|
||||
instance: string,
|
||||
action: string
|
||||
): Response {
|
||||
const log = logger.withContext({ requestId });
|
||||
|
||||
if (error instanceof OperationNotAllowedError) {
|
||||
log.warn({ statusCode: 403, errorCode: error.name }, `Survey ${action} forbidden`);
|
||||
return problemForbidden(requestId, error.message, instance);
|
||||
}
|
||||
|
||||
if (error instanceof InvalidInputError || error instanceof ValidationError) {
|
||||
log.warn({ statusCode: 400, errorCode: error.name }, `Survey ${action} validation failed`);
|
||||
return problemBadRequest(requestId, error.message, {
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof DatabaseError) {
|
||||
log.error({ error, statusCode: 500 }, `Database error during survey ${action}`);
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
log.error({ error, statusCode: 500 }, `V3 survey ${action} unexpected error`);
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
export const GET = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
params: ZV3SurveyRouteParams,
|
||||
},
|
||||
handler: async ({ authentication, parsedInput, requestId, instance }) => {
|
||||
const log = logger.withContext({ requestId, surveyId: parsedInput.params.surveyId });
|
||||
|
||||
try {
|
||||
const authResult = await requireV3SurveyAccess(
|
||||
authentication,
|
||||
parsedInput.params.surveyId,
|
||||
"read",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
return successResponse(serializeV3SurveyResource(authResult.survey), {
|
||||
requestId,
|
||||
cache: "private, no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
log.error({ error, statusCode: 500 }, "Database error during survey fetch");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
log.error({ error, statusCode: 500 }, "V3 survey fetch unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const PATCH = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
params: ZV3SurveyRouteParams,
|
||||
body: ZV3SurveyPatchBody,
|
||||
},
|
||||
action: "updated",
|
||||
targetType: "survey",
|
||||
handler: async ({ authentication, parsedInput, requestId, instance, auditLog }) => {
|
||||
try {
|
||||
const authResult = await requireV3SurveyAccess(
|
||||
authentication,
|
||||
parsedInput.params.surveyId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const updatedSurveyInput = applyV3SurveyPatch(authResult.survey, parsedInput.body);
|
||||
await checkExternalUrlsPermission(authResult.organizationId, updatedSurveyInput, authResult.survey);
|
||||
|
||||
const survey = await updateSurvey(updatedSurveyInput);
|
||||
const serializedSurvey = serializeV3SurveyResource(survey);
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.organizationId = authResult.organizationId;
|
||||
auditLog.targetId = survey.id;
|
||||
auditLog.oldObject = serializeV3SurveyResource(authResult.survey);
|
||||
auditLog.newObject = serializedSurvey;
|
||||
}
|
||||
|
||||
return successResponse(serializedSurvey, {
|
||||
requestId,
|
||||
cache: "private, no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleSurveyMutationError(error, requestId, instance, "update");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const DELETE = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
params: ZV3SurveyRouteParams,
|
||||
},
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
handler: async ({ authentication, parsedInput, requestId, instance, auditLog }) => {
|
||||
try {
|
||||
const authResult = await requireV3SurveyAccess(
|
||||
authentication,
|
||||
parsedInput.params.surveyId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
await deleteSurvey(parsedInput.params.surveyId);
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.organizationId = authResult.organizationId;
|
||||
auditLog.targetId = parsedInput.params.surveyId;
|
||||
auditLog.oldObject = serializeV3SurveyResource(authResult.survey);
|
||||
}
|
||||
|
||||
return noContentResponse({
|
||||
requestId,
|
||||
cache: "private, no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
return handleSurveyMutationError(error, requestId, instance, "delete");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ValidationError } from "@formbricks/types/errors";
|
||||
import { applyV3SurveyPatch, buildV3SurveyCreateInput, buildV3SurveyPreview } from "./adapters";
|
||||
|
||||
const workspaceId = createId();
|
||||
const surveyId = createId();
|
||||
|
||||
function buildCreateBody() {
|
||||
return {
|
||||
workspaceId,
|
||||
name: "Adapter Survey",
|
||||
blocks: [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Intro",
|
||||
elements: [
|
||||
{
|
||||
id: "question_1",
|
||||
type: "openText",
|
||||
headline: { default: "What should we improve?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("v3 survey adapters", () => {
|
||||
test("buildV3SurveyCreateInput injects defaults and creator identity", () => {
|
||||
const result = buildV3SurveyCreateInput(buildCreateBody(), "user_1");
|
||||
|
||||
expect(result.createdBy).toBe("user_1");
|
||||
expect(result.type).toBe("link");
|
||||
expect(result.status).toBe("draft");
|
||||
expect(result.questions).toEqual([]);
|
||||
expect(result.followUps).toEqual([]);
|
||||
expect(result.hiddenFields).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
test("buildV3SurveyPreview creates a full survey resource candidate", () => {
|
||||
const createInput = buildV3SurveyCreateInput(buildCreateBody(), null);
|
||||
const survey = buildV3SurveyPreview(workspaceId, createInput, surveyId);
|
||||
|
||||
expect(survey.id).toBe(surveyId);
|
||||
expect(survey.environmentId).toBe(workspaceId);
|
||||
expect(survey.createdBy).toBeNull();
|
||||
expect(survey.name).toBe("Adapter Survey");
|
||||
expect(survey.questions).toEqual([]);
|
||||
});
|
||||
|
||||
test("buildV3SurveyCreateInput throws for invalid create payloads", () => {
|
||||
expect(() =>
|
||||
buildV3SurveyCreateInput(
|
||||
{
|
||||
...buildCreateBody(),
|
||||
blocks: [],
|
||||
} as any,
|
||||
"user_1"
|
||||
)
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("buildV3SurveyPreview throws when the generated survey candidate is invalid", () => {
|
||||
expect(() =>
|
||||
buildV3SurveyPreview(
|
||||
workspaceId,
|
||||
{
|
||||
...buildV3SurveyCreateInput(buildCreateBody(), "user_1"),
|
||||
blocks: [],
|
||||
} as any,
|
||||
surveyId
|
||||
)
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("applyV3SurveyPatch replaces nested subtrees and preserves omitted top-level fields", () => {
|
||||
const currentSurvey = buildV3SurveyPreview(
|
||||
workspaceId,
|
||||
buildV3SurveyCreateInput(
|
||||
{
|
||||
...buildCreateBody(),
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome" },
|
||||
},
|
||||
},
|
||||
"user_1"
|
||||
),
|
||||
surveyId
|
||||
);
|
||||
|
||||
const updatedSurvey = applyV3SurveyPatch(currentSurvey, {
|
||||
name: "Patched Survey",
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedSurvey.name).toBe("Patched Survey");
|
||||
expect(updatedSurvey.status).toBe(currentSurvey.status);
|
||||
expect(updatedSurvey.blocks).toEqual(currentSurvey.blocks);
|
||||
expect(updatedSurvey.welcomeCard).toEqual({
|
||||
enabled: false,
|
||||
timeToFinish: true,
|
||||
showResponseCount: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("applyV3SurveyPatch throws when a patch would make the survey invalid", () => {
|
||||
const currentSurvey = buildV3SurveyPreview(
|
||||
workspaceId,
|
||||
buildV3SurveyCreateInput(buildCreateBody(), "user_1"),
|
||||
surveyId
|
||||
);
|
||||
|
||||
expect(() => applyV3SurveyPatch(currentSurvey, { blocks: [] })).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { z } from "zod";
|
||||
import { ValidationError } from "@formbricks/types/errors";
|
||||
import { TSurvey, TSurveyCreateInput, ZSurvey, ZSurveyCreateInput } from "@formbricks/types/surveys/types";
|
||||
import type { TV3SurveyCreateBody, TV3SurveyPatchBody } from "./schemas";
|
||||
|
||||
const V3_SURVEY_SYSTEM_DEFAULTS = {
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
questions: [],
|
||||
followUps: [],
|
||||
delay: 0,
|
||||
autoComplete: null,
|
||||
projectOverwrites: null,
|
||||
styling: null,
|
||||
showLanguageSwitch: null,
|
||||
surveyClosedMessage: null,
|
||||
segment: null,
|
||||
singleUse: null,
|
||||
isVerifyEmailEnabled: false,
|
||||
recaptcha: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
isCaptureIpEnabled: false,
|
||||
pin: null,
|
||||
displayPercentage: null,
|
||||
languages: [],
|
||||
metadata: {},
|
||||
slug: null,
|
||||
customHeadScripts: null,
|
||||
customHeadScriptsMode: null,
|
||||
} satisfies Omit<
|
||||
TSurvey,
|
||||
| "id"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
| "environmentId"
|
||||
| "createdBy"
|
||||
| "name"
|
||||
| "type"
|
||||
| "status"
|
||||
| "welcomeCard"
|
||||
| "blocks"
|
||||
| "endings"
|
||||
| "hiddenFields"
|
||||
| "variables"
|
||||
>;
|
||||
|
||||
function formatValidationError(error: z.ZodError): string {
|
||||
return error.issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.length > 0 ? `${issue.path.join(".")}: ` : "";
|
||||
return `${path}${issue.message}`;
|
||||
})
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
function toValidationError(error: z.ZodError): ValidationError {
|
||||
return new ValidationError(formatValidationError(error));
|
||||
}
|
||||
|
||||
export function normalizeV3SurveyCreateInput(
|
||||
body: TV3SurveyCreateBody,
|
||||
createdBy: string | null
|
||||
): TSurveyCreateInput {
|
||||
return {
|
||||
...V3_SURVEY_SYSTEM_DEFAULTS,
|
||||
name: body.name,
|
||||
type: body.type ?? "link",
|
||||
status: body.status ?? "draft",
|
||||
welcomeCard: body.welcomeCard ?? {
|
||||
enabled: false,
|
||||
},
|
||||
blocks: body.blocks,
|
||||
endings: body.endings ?? [],
|
||||
hiddenFields: body.hiddenFields ?? { enabled: false },
|
||||
variables: body.variables ?? [],
|
||||
createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildV3SurveyCreateInput(
|
||||
body: TV3SurveyCreateBody,
|
||||
createdBy: string | null
|
||||
): TSurveyCreateInput {
|
||||
const input = normalizeV3SurveyCreateInput(body, createdBy);
|
||||
const result = ZSurveyCreateInput.safeParse(input);
|
||||
|
||||
if (!result.success) {
|
||||
throw toValidationError(result.error);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function buildV3SurveyPreview(
|
||||
environmentId: string,
|
||||
createInput: TSurveyCreateInput,
|
||||
surveyId = createId()
|
||||
): TSurvey {
|
||||
const now = new Date();
|
||||
const surveyCandidate: TSurvey = {
|
||||
...V3_SURVEY_SYSTEM_DEFAULTS,
|
||||
id: surveyId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
environmentId,
|
||||
createdBy: createInput.createdBy ?? null,
|
||||
name: createInput.name,
|
||||
type: createInput.type ?? "link",
|
||||
status: createInput.status ?? "draft",
|
||||
welcomeCard: createInput.welcomeCard ?? {
|
||||
enabled: false,
|
||||
},
|
||||
blocks: createInput.blocks ?? [],
|
||||
endings: createInput.endings ?? [],
|
||||
hiddenFields: createInput.hiddenFields ?? { enabled: false },
|
||||
variables: createInput.variables ?? [],
|
||||
};
|
||||
|
||||
const result = ZSurvey.safeParse(surveyCandidate);
|
||||
if (!result.success) {
|
||||
throw toValidationError(result.error);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function applyV3SurveyPatch(currentSurvey: TSurvey, patch: TV3SurveyPatchBody): TSurvey {
|
||||
const mergedSurvey: TSurvey = {
|
||||
...currentSurvey,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const result = ZSurvey.safeParse(mergedSurvey);
|
||||
if (!result.success) {
|
||||
throw toValidationError(result.error);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { ApiKeyPermission, EnvironmentType } 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 { DatabaseError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { GET } from "./route";
|
||||
import { buildV3SurveyCreateInput, buildV3SurveyPreview } from "./adapters";
|
||||
import { GET, POST } from "./route";
|
||||
import { ZV3SurveyCreateBody } from "./schemas";
|
||||
|
||||
const { mockAuthenticateRequest } = vi.hoisted(() => ({
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
@@ -34,6 +40,22 @@ vi.mock("@/app/api/v3/lib/auth", () => ({
|
||||
requireV3WorkspaceAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
createSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/editor/lib/check-external-urls-permission", () => ({
|
||||
checkExternalUrlsPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/ee/audit-logs/lib/handler")>();
|
||||
return {
|
||||
...actual,
|
||||
queueAuditEvent: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
|
||||
return {
|
||||
@@ -63,6 +85,27 @@ const getServerSession = vi.mocked((await import("next-auth")).getServerSession)
|
||||
|
||||
const validWorkspaceId = "clxx1234567890123456789012";
|
||||
const resolvedEnvironmentId = "clzz9876543210987654321098";
|
||||
const surveyId = "clsv1234567890123456789012";
|
||||
|
||||
const createBody = {
|
||||
workspaceId: validWorkspaceId,
|
||||
name: "API Survey",
|
||||
blocks: [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Intro",
|
||||
elements: [
|
||||
{
|
||||
id: "question_1",
|
||||
type: "openText",
|
||||
headline: { default: "How can we help?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const parsedCreateBody = ZV3SurveyCreateBody.parse(createBody);
|
||||
|
||||
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
|
||||
const headers: Record<string, string> = { ...extraHeaders };
|
||||
@@ -124,6 +167,15 @@ describe("GET /api/v3/surveys", () => {
|
||||
});
|
||||
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
|
||||
vi.mocked(getSurveyCount).mockResolvedValue(0);
|
||||
vi.mocked(checkExternalUrlsPermission).mockResolvedValue(undefined);
|
||||
vi.mocked(queueAuditEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(createSurvey).mockResolvedValue(
|
||||
buildV3SurveyPreview(
|
||||
resolvedEnvironmentId,
|
||||
buildV3SurveyCreateInput(parsedCreateBody, "user_1"),
|
||||
surveyId
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -355,3 +407,109 @@ describe("GET /api/v3/surveys", () => {
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /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).mockResolvedValue({
|
||||
environmentId: resolvedEnvironmentId,
|
||||
projectId: "proj_1",
|
||||
organizationId: "org_1",
|
||||
} as any);
|
||||
vi.mocked(checkExternalUrlsPermission).mockResolvedValue(undefined);
|
||||
vi.mocked(queueAuditEvent).mockResolvedValue(undefined);
|
||||
vi.mocked(createSurvey).mockResolvedValue(
|
||||
buildV3SurveyPreview(
|
||||
resolvedEnvironmentId,
|
||||
buildV3SurveyCreateInput(parsedCreateBody, "user_1"),
|
||||
surveyId
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("creates a survey and returns 201 with location header", async () => {
|
||||
const requestId = "req-create";
|
||||
const req = new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": requestId,
|
||||
},
|
||||
body: JSON.stringify(createBody),
|
||||
});
|
||||
|
||||
const res = await POST(req, {} as any);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.headers.get("Location")).toBe(`/api/v3/surveys/${surveyId}`);
|
||||
expect(res.headers.get("X-Request-Id")).toBe(requestId);
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: expect.any(Object) }),
|
||||
validWorkspaceId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
"/api/v3/surveys"
|
||||
);
|
||||
expect(checkExternalUrlsPermission).toHaveBeenCalled();
|
||||
expect(createSurvey).toHaveBeenCalledWith(
|
||||
resolvedEnvironmentId,
|
||||
buildV3SurveyCreateInput(parsedCreateBody, "user_1")
|
||||
);
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "created",
|
||||
targetType: "survey",
|
||||
organizationId: "org_1",
|
||||
targetId: surveyId,
|
||||
status: "success",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 400 when unsupported top-level fields are provided", async () => {
|
||||
const req = new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...createBody,
|
||||
questions: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const res = await POST(req, {} as any);
|
||||
expect(res.status).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.invalid_params).toContainEqual({
|
||||
name: "questions",
|
||||
reason: "Unsupported field",
|
||||
});
|
||||
expect(createSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when external url permission blocks creation", async () => {
|
||||
vi.mocked(checkExternalUrlsPermission).mockRejectedValueOnce(
|
||||
new OperationNotAllowedError("External URLs are not enabled")
|
||||
);
|
||||
const req = new NextRequest("http://localhost/api/v3/surveys", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(createBody),
|
||||
});
|
||||
|
||||
const res = await POST(req, {} as any);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
/**
|
||||
* GET /api/v3/surveys — list surveys for a workspace.
|
||||
* Session cookie or x-api-key; scope by workspaceId only.
|
||||
* /api/v3/surveys — list and create surveys for a workspace.
|
||||
*/
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import {
|
||||
DatabaseError,
|
||||
InvalidInputError,
|
||||
OperationNotAllowedError,
|
||||
ResourceNotFoundError,
|
||||
ValidationError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import {
|
||||
createdResponse,
|
||||
problemBadRequest,
|
||||
problemForbidden,
|
||||
problemInternalError,
|
||||
successListResponse,
|
||||
} from "@/app/api/v3/lib/response";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { checkExternalUrlsPermission } from "@/modules/survey/editor/lib/check-external-urls-permission";
|
||||
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
|
||||
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
|
||||
import { buildV3SurveyCreateInput, buildV3SurveyPreview } from "./adapters";
|
||||
import { parseV3SurveysListQuery } from "./parse-v3-surveys-list-query";
|
||||
import { serializeV3SurveyListItem } from "./serializers";
|
||||
import { ZV3SurveyCreateBody } from "./schemas";
|
||||
import { serializeV3SurveyListItem, serializeV3SurveyResource } from "./serializers";
|
||||
|
||||
export const GET = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
@@ -79,3 +89,75 @@ export const GET = withV3ApiWrapper({
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const POST = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
schemas: {
|
||||
body: ZV3SurveyCreateBody,
|
||||
},
|
||||
action: "created",
|
||||
targetType: "survey",
|
||||
handler: async ({ authentication, parsedInput, requestId, instance, auditLog }) => {
|
||||
const log = logger.withContext({ requestId });
|
||||
|
||||
try {
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
parsedInput.body.workspaceId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
const createdBy =
|
||||
authentication && "user" in authentication && authentication.user?.id ? authentication.user.id : null;
|
||||
const createInput = buildV3SurveyCreateInput(parsedInput.body, createdBy);
|
||||
const surveyPreview = buildV3SurveyPreview(authResult.environmentId, createInput);
|
||||
|
||||
await checkExternalUrlsPermission(authResult.organizationId, surveyPreview, null);
|
||||
|
||||
const survey = await createSurvey(authResult.environmentId, createInput);
|
||||
const serializedSurvey = serializeV3SurveyResource(survey);
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.organizationId = authResult.organizationId;
|
||||
auditLog.targetId = survey.id;
|
||||
auditLog.newObject = serializedSurvey;
|
||||
}
|
||||
|
||||
return createdResponse(serializedSurvey, {
|
||||
requestId,
|
||||
cache: "private, no-store",
|
||||
location: `/api/v3/surveys/${survey.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof OperationNotAllowedError) {
|
||||
log.warn({ statusCode: 403, errorCode: error.name }, "Survey creation forbidden");
|
||||
return problemForbidden(requestId, error.message, instance);
|
||||
}
|
||||
|
||||
if (error instanceof InvalidInputError || error instanceof ValidationError) {
|
||||
log.warn({ statusCode: 400, errorCode: error.name }, "Survey creation validation failed");
|
||||
return problemBadRequest(requestId, error.message, {
|
||||
instance,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof DatabaseError) {
|
||||
log.error({ error, statusCode: 500 }, "Database error during survey creation");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
log.error({ error, statusCode: 500 }, "Missing resource during survey creation");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
log.error({ error, statusCode: 500 }, "V3 survey create unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ZV3SurveyCreateBody, ZV3SurveyPatchBody } from "./schemas";
|
||||
|
||||
const workspaceId = createId();
|
||||
|
||||
function buildCreateBody() {
|
||||
return {
|
||||
workspaceId,
|
||||
name: "Schema Survey",
|
||||
blocks: [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Intro",
|
||||
elements: [
|
||||
{
|
||||
id: "question_1",
|
||||
type: "openText",
|
||||
headline: { default: "How did it go?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("v3 survey schemas", () => {
|
||||
test("applies public defaults for create requests", () => {
|
||||
const result = ZV3SurveyCreateBody.parse(buildCreateBody());
|
||||
|
||||
expect(result.type).toBe("link");
|
||||
expect(result.status).toBe("draft");
|
||||
expect(result.welcomeCard).toEqual({
|
||||
enabled: false,
|
||||
timeToFinish: true,
|
||||
showResponseCount: false,
|
||||
});
|
||||
expect(result.endings).toEqual([]);
|
||||
expect(result.hiddenFields).toEqual({ enabled: false });
|
||||
expect(result.variables).toEqual([]);
|
||||
});
|
||||
|
||||
test("rejects unsupported create fields", () => {
|
||||
const result = ZV3SurveyCreateBody.safeParse({
|
||||
...buildCreateBody(),
|
||||
questions: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.code === "unrecognized_keys")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid nested block logic on create", () => {
|
||||
const missingTarget = createId();
|
||||
const result = ZV3SurveyCreateBody.safeParse({
|
||||
...buildCreateBody(),
|
||||
blocks: [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Logic block",
|
||||
elements: [
|
||||
{
|
||||
id: "question_1",
|
||||
type: "openText",
|
||||
headline: { default: "How did it go?" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
logic: [
|
||||
{
|
||||
id: createId(),
|
||||
conditions: {
|
||||
id: createId(),
|
||||
connector: "and",
|
||||
conditions: [
|
||||
{
|
||||
id: createId(),
|
||||
leftOperand: {
|
||||
type: "element",
|
||||
value: "question_1",
|
||||
},
|
||||
operator: "isSubmitted",
|
||||
},
|
||||
],
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
id: createId(),
|
||||
objective: "jumpToBlock",
|
||||
target: missingTarget,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.path.join(".").includes("blocks"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid hidden field identifiers", () => {
|
||||
const result = ZV3SurveyCreateBody.safeParse({
|
||||
...buildCreateBody(),
|
||||
hiddenFields: {
|
||||
enabled: true,
|
||||
fieldIds: ["userId", "bad field"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.path.join(".").includes("hiddenFields"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects invalid variable names", () => {
|
||||
const result = ZV3SurveyCreateBody.safeParse({
|
||||
...buildCreateBody(),
|
||||
variables: [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Bad-Variable",
|
||||
type: "text",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.path.join(".").includes("variables"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts strict top-level partial patch requests", () => {
|
||||
const result = ZV3SurveyPatchBody.parse({
|
||||
name: "Updated name",
|
||||
status: "inProgress",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
name: "Updated name",
|
||||
status: "inProgress",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects immutable patch fields", () => {
|
||||
const result = ZV3SurveyPatchBody.safeParse({
|
||||
id: createId(),
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((issue) => issue.code === "unrecognized_keys")).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
import { z } from "zod";
|
||||
import { ZEndingCardUrl, ZId, ZStorageUrl } from "@formbricks/types/common";
|
||||
import { ZI18nString } from "@formbricks/types/i18n";
|
||||
import { ZSurveyBlocks } from "@formbricks/types/surveys/blocks";
|
||||
import { ZSurveyCreateInput, ZSurveyStatus, ZSurveyType } from "@formbricks/types/surveys/types";
|
||||
import { FORBIDDEN_IDS } from "@formbricks/types/surveys/validation";
|
||||
import { normalizeV3SurveyCreateInput } from "./adapters";
|
||||
|
||||
const ZV3SurveyEndScreen = z.strictObject({
|
||||
id: z.cuid2(),
|
||||
type: z.literal("endScreen"),
|
||||
headline: ZI18nString.optional(),
|
||||
subheader: ZI18nString.optional(),
|
||||
buttonLabel: ZI18nString.optional(),
|
||||
buttonLink: ZEndingCardUrl.optional(),
|
||||
imageUrl: ZStorageUrl.optional(),
|
||||
videoUrl: ZStorageUrl.optional(),
|
||||
});
|
||||
|
||||
const ZV3SurveyRedirectEnding = z.strictObject({
|
||||
id: z.cuid2(),
|
||||
type: z.literal("redirectToUrl"),
|
||||
url: ZEndingCardUrl.optional(),
|
||||
label: z.string().optional(),
|
||||
});
|
||||
|
||||
const ZV3SurveyEndings = z.array(z.union([ZV3SurveyEndScreen, ZV3SurveyRedirectEnding]));
|
||||
|
||||
const ZV3SurveyWelcomeCard = z
|
||||
.strictObject({
|
||||
enabled: z.boolean(),
|
||||
headline: ZI18nString.optional(),
|
||||
subheader: ZI18nString.optional(),
|
||||
fileUrl: ZStorageUrl.optional(),
|
||||
buttonLabel: ZI18nString.optional(),
|
||||
timeToFinish: z.boolean().prefault(true),
|
||||
showResponseCount: z.boolean().prefault(false),
|
||||
videoUrl: ZStorageUrl.optional(),
|
||||
})
|
||||
.refine((value) => !(value.enabled && !value.headline), {
|
||||
error: "Welcome card must have a headline",
|
||||
});
|
||||
|
||||
const ZV3HiddenFieldId = z.string().superRefine((field, ctx) => {
|
||||
if (FORBIDDEN_IDS.includes(field)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Hidden field id is not allowed",
|
||||
});
|
||||
}
|
||||
|
||||
if (field.includes(" ")) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Hidden field id not allowed, avoid using spaces.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(field)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Hidden field id not allowed, use only alphanumeric characters, hyphens, or underscores.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ZV3SurveyHiddenFields = z.strictObject({
|
||||
enabled: z.boolean(),
|
||||
fieldIds: z.array(ZV3HiddenFieldId).optional(),
|
||||
});
|
||||
|
||||
const ZV3SurveyVariable = z
|
||||
.discriminatedUnion("type", [
|
||||
z.strictObject({
|
||||
id: z.cuid2(),
|
||||
name: z.string(),
|
||||
type: z.literal("number"),
|
||||
value: z.number().prefault(0),
|
||||
}),
|
||||
z.strictObject({
|
||||
id: z.cuid2(),
|
||||
name: z.string(),
|
||||
type: z.literal("text"),
|
||||
value: z.string().prefault(""),
|
||||
}),
|
||||
])
|
||||
.superRefine((value, ctx) => {
|
||||
if (!/^[a-z0-9_]+$/.test(value.name)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Variable name can only contain lowercase letters, numbers, and underscores",
|
||||
path: ["name"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const ZV3SurveyVariables = z.array(ZV3SurveyVariable);
|
||||
|
||||
function addCreateInputIssues(body: TV3SurveyCreateBody, ctx: z.RefinementCtx): void {
|
||||
const result = ZSurveyCreateInput.safeParse(normalizeV3SurveyCreateInput(body, null));
|
||||
if (result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const issue of result.error.issues) {
|
||||
ctx.addIssue(issue as any);
|
||||
}
|
||||
}
|
||||
|
||||
export const ZV3SurveyCreateBody = z
|
||||
.strictObject({
|
||||
workspaceId: ZId,
|
||||
name: z.string().trim().min(1),
|
||||
type: ZSurveyType.default("link"),
|
||||
status: ZSurveyStatus.default("draft"),
|
||||
welcomeCard: ZV3SurveyWelcomeCard.prefault({
|
||||
enabled: false,
|
||||
}),
|
||||
blocks: ZSurveyBlocks.min(1, {
|
||||
error: "Survey must have at least one block",
|
||||
}),
|
||||
endings: ZV3SurveyEndings.default([]),
|
||||
hiddenFields: ZV3SurveyHiddenFields.prefault({
|
||||
enabled: false,
|
||||
}),
|
||||
variables: ZV3SurveyVariables.default([]),
|
||||
})
|
||||
.superRefine(addCreateInputIssues);
|
||||
|
||||
export const ZV3SurveyPatchBody = z
|
||||
.strictObject({
|
||||
name: z.string().trim().min(1).optional(),
|
||||
type: ZSurveyType.optional(),
|
||||
status: ZSurveyStatus.optional(),
|
||||
welcomeCard: ZV3SurveyWelcomeCard.optional(),
|
||||
blocks: ZSurveyBlocks.optional(),
|
||||
endings: ZV3SurveyEndings.optional(),
|
||||
hiddenFields: ZV3SurveyHiddenFields.optional(),
|
||||
variables: ZV3SurveyVariables.optional(),
|
||||
})
|
||||
.superRefine((body, ctx) => {
|
||||
if (Object.keys(body).length === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Request body must include at least one updatable field",
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const ZV3SurveyRouteParams = z.strictObject({
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export type TV3SurveyCreateBody = z.infer<typeof ZV3SurveyCreateBody>;
|
||||
export type TV3SurveyPatchBody = z.infer<typeof ZV3SurveyPatchBody>;
|
||||
export type TV3SurveyRouteParams = z.infer<typeof ZV3SurveyRouteParams>;
|
||||
@@ -1,9 +1,25 @@
|
||||
import type { TSurvey as TFullSurvey } from "@formbricks/types/surveys/types";
|
||||
import type { TSurvey } from "@/modules/survey/list/types/surveys";
|
||||
|
||||
export type TV3SurveyListItem = Omit<TSurvey, "environmentId" | "singleUse"> & {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
export type TV3SurveyResource = {
|
||||
id: TFullSurvey["id"];
|
||||
workspaceId: string;
|
||||
createdAt: TFullSurvey["createdAt"];
|
||||
updatedAt: TFullSurvey["updatedAt"];
|
||||
name: TFullSurvey["name"];
|
||||
type: TFullSurvey["type"];
|
||||
status: TFullSurvey["status"];
|
||||
welcomeCard: TFullSurvey["welcomeCard"];
|
||||
blocks: TFullSurvey["blocks"];
|
||||
endings: TFullSurvey["endings"];
|
||||
hiddenFields: TFullSurvey["hiddenFields"];
|
||||
variables: TFullSurvey["variables"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep the v3 API contract isolated from internal persistence naming.
|
||||
* Internally surveys are still scoped by environmentId; externally v3 exposes workspaceId.
|
||||
@@ -16,3 +32,20 @@ export function serializeV3SurveyListItem(survey: TSurvey): TV3SurveyListItem {
|
||||
workspaceId: environmentId,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeV3SurveyResource(survey: TFullSurvey): TV3SurveyResource {
|
||||
return {
|
||||
id: survey.id,
|
||||
workspaceId: survey.environmentId,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
name: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
welcomeCard: survey.welcomeCard,
|
||||
blocks: survey.blocks,
|
||||
endings: survey.endings,
|
||||
hiddenFields: survey.hiddenFields,
|
||||
variables: survey.variables,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("conflictResponse", () => {
|
||||
test("should return a conflict response", () => {
|
||||
const message = "Resource already exists";
|
||||
const details = { field: "singleUseId" };
|
||||
const response = responses.conflictResponse(message, details);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "conflict",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle undefined details", () => {
|
||||
const message = "Resource already exists";
|
||||
const response = responses.conflictResponse(message);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "conflict",
|
||||
message,
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should include CORS headers when cors is true", () => {
|
||||
const message = "Resource already exists";
|
||||
const response = responses.conflictResponse(message, undefined, true);
|
||||
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Resource already exists";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.conflictResponse(message, undefined, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tooManyRequestsResponse", () => {
|
||||
test("should return a too many requests response", () => {
|
||||
const message = "Rate limit exceeded";
|
||||
|
||||
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
|
||||
| "method_not_allowed"
|
||||
| "not_authenticated"
|
||||
| "forbidden"
|
||||
| "too_many_requests";
|
||||
| "too_many_requests"
|
||||
| "conflict";
|
||||
message: string;
|
||||
details: {
|
||||
[key: string]: string | string[] | number | number[] | boolean | boolean[];
|
||||
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
|
||||
);
|
||||
};
|
||||
|
||||
const conflictResponse = (
|
||||
message: string,
|
||||
details?: { [key: string]: string },
|
||||
cors: boolean = false,
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
code: "conflict",
|
||||
message,
|
||||
details: details || {},
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 409,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const tooManyRequestsResponse = (
|
||||
message: string,
|
||||
cors: boolean = false,
|
||||
@@ -270,4 +295,5 @@ export const responses = {
|
||||
successResponse,
|
||||
tooManyRequestsResponse,
|
||||
forbiddenResponse,
|
||||
conflictResponse,
|
||||
};
|
||||
|
||||
@@ -4926,6 +4926,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
isCaptureIpEnabled: false,
|
||||
metadata: {},
|
||||
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
||||
|
||||
@@ -51,6 +51,8 @@ checksums:
|
||||
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
|
||||
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
|
||||
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
|
||||
auth/login/oauth_account_not_linked_description: 74627dc30666699b21de093d16d83312
|
||||
auth/login/oauth_account_not_linked_title: 2eb8e132ed37b3b87c1dec392c224933
|
||||
auth/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
|
||||
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
|
||||
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
|
||||
@@ -411,6 +413,7 @@ checksums:
|
||||
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
||||
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
||||
common/teams: b63448c05270497973ac4407047dae02
|
||||
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/time: b504a03d52e8001bfdc5cb6205364f42
|
||||
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
|
||||
@@ -1285,6 +1288,8 @@ checksums:
|
||||
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
||||
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
|
||||
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
|
||||
environments/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
|
||||
environments/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
|
||||
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
|
||||
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
|
||||
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
|
||||
@@ -2021,6 +2026,7 @@ checksums:
|
||||
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
|
||||
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
|
||||
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
||||
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
||||
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
||||
|
||||
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
|
||||
|
||||
if (Array.isArray(responseValue)) {
|
||||
// Multiple choice case - response is an array of selected choice labels
|
||||
return responseValue.map(findChoiceByLabel).filter((choiceId): choiceId is string => choiceId !== null);
|
||||
// Filter out empty string sentinel used as "other" marker in multipleChoiceMulti
|
||||
return responseValue
|
||||
.filter((v) => v !== "")
|
||||
.map(findChoiceByLabel)
|
||||
.filter((choiceId): choiceId is string => choiceId !== null);
|
||||
} else if (typeof responseValue === "string") {
|
||||
// Single choice case - response is a single choice label
|
||||
const choiceId = findChoiceByLabel(responseValue);
|
||||
|
||||
@@ -209,6 +209,7 @@ const baseSurveyProperties = {
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
isCaptureIpEnabled: false,
|
||||
endings: [
|
||||
{
|
||||
|
||||
@@ -48,6 +48,7 @@ export const selectSurvey = {
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Zuweisen =",
|
||||
"audience": "Publikum",
|
||||
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
|
||||
"auto_progress_rating_and_nps": "Bewertungs- und NPS-Fragen automatisch fortsetzen",
|
||||
"auto_progress_rating_and_nps_description": "Fahre automatisch fort, sobald Befragte eine Antwort bei Bewertungs- oder NPS-Fragen auswählen. Dies gilt nur für Blöcke mit einer einzelnen Frage. Bei Pflichtfragen wird die Weiter-Schaltfläche ausgeblendet; bei optionalen Fragen bleibt sie zum Überspringen sichtbar.",
|
||||
"auto_save_disabled": "Automatisches Speichern deaktiviert",
|
||||
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
|
||||
"auto_save_on": "Automatisches Speichern an",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
"time_to_complete": "Zeit zur Fertigstellung",
|
||||
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
|
||||
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
|
||||
"unknown_question_type": "Unbekannter Fragetyp",
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Assign =",
|
||||
"audience": "Audience",
|
||||
"auto_close_on_inactivity": "Auto close on inactivity",
|
||||
"auto_progress_rating_and_nps": "Auto-progress rating and NPS questions",
|
||||
"auto_progress_rating_and_nps_description": "Automatically advance when respondents select an answer on rating or NPS questions. This only applies to single-question blocks. Required questions hide the Next button; optional questions still show it for skipping.",
|
||||
"auto_save_disabled": "Auto-save disabled",
|
||||
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
|
||||
"auto_save_on": "Auto-save on",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
"time_to_complete": "Time to Complete",
|
||||
"ttc_survey_tooltip": "Average time to complete the survey.",
|
||||
"ttc_tooltip": "Average time to complete the question.",
|
||||
"unknown_question_type": "Unknown Question Type",
|
||||
"use_personal_links": "Use personal links",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Asignar =",
|
||||
"audience": "Audiencia",
|
||||
"auto_close_on_inactivity": "Cierre automático por inactividad",
|
||||
"auto_progress_rating_and_nps": "Avanzar automáticamente en preguntas de valoración y NPS",
|
||||
"auto_progress_rating_and_nps_description": "Avanza automáticamente cuando los encuestados seleccionen una respuesta en preguntas de valoración o NPS. Esto solo se aplica a bloques de una sola pregunta. Las preguntas obligatorias ocultan el botón Siguiente; las preguntas opcionales aún lo muestran para omitirlas.",
|
||||
"auto_save_disabled": "Guardado automático desactivado",
|
||||
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||
"auto_save_on": "Guardado automático activado",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este año",
|
||||
"time_to_complete": "Tiempo para completar",
|
||||
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
|
||||
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
|
||||
"unknown_question_type": "Tipo de pregunta desconocido",
|
||||
"use_personal_links": "Usar enlaces personales",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Attribuer =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
|
||||
"auto_progress_rating_and_nps": "Progression automatique pour les questions d'évaluation et NPS",
|
||||
"auto_progress_rating_and_nps_description": "Passe automatiquement à la question suivante lorsque les répondants sélectionnent une réponse aux questions d'évaluation ou NPS. Cela s'applique uniquement aux blocs à question unique. Les questions obligatoires masquent le bouton Suivant ; les questions facultatives l'affichent toujours pour permettre de passer la question.",
|
||||
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||
"auto_save_on": "Sauvegarde automatique activée",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Ce trimestre",
|
||||
"this_year": "Cette année",
|
||||
"time_to_complete": "Temps à compléter",
|
||||
"ttc_survey_tooltip": "Temps moyen pour compléter le sondage.",
|
||||
"ttc_tooltip": "Temps moyen pour compléter la question.",
|
||||
"unknown_question_type": "Type de question inconnu",
|
||||
"use_personal_links": "Utilisez des liens personnels",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "= hozzárendelése",
|
||||
"audience": "Közönség",
|
||||
"auto_close_on_inactivity": "Automatikus lezárás tétlenségnél",
|
||||
"auto_progress_rating_and_nps": "Automatikus továbblépés értékelési és NPS kérdéseknél",
|
||||
"auto_progress_rating_and_nps_description": "Automatikus továbblépés, amikor a válaszadók kiválasztanak egy választ az értékelési vagy NPS kérdéseknél. Ez csak az egykérdéses blokkokra vonatkozik. A kötelező kérdések elrejtik a Tovább gombot; az opcionális kérdések továbbra is megjelenítik azt a kihagyás lehetősége érdekében.",
|
||||
"auto_save_disabled": "Az automatikus mentés letiltva",
|
||||
"auto_save_disabled_tooltip": "A kérdőív csak akkor kerül automatikusan mentésre, ha piszkozatban van. Ez biztosítja, hogy a nyilvános kérdőívek ne legyenek véletlenül frissítve.",
|
||||
"auto_save_on": "Automatikus mentés bekapcsolva",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Ez a negyedév",
|
||||
"this_year": "Ez az év",
|
||||
"time_to_complete": "Kitöltéshez szükséges idő",
|
||||
"ttc_survey_tooltip": "A felmérés kitöltésének átlagos ideje.",
|
||||
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
|
||||
"unknown_question_type": "Ismeretlen kérdéstípus",
|
||||
"use_personal_links": "Személyes hivatkozások használata",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "割り当て =",
|
||||
"audience": "オーディエンス",
|
||||
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
|
||||
"auto_progress_rating_and_nps": "評価とNPSの質問を自動進行",
|
||||
"auto_progress_rating_and_nps_description": "評価またはNPSの質問で回答者が選択肢を選んだ際に自動的に次へ進みます。これは単一質問ブロックにのみ適用されます。必須の質問では「次へ」ボタンが非表示になり、任意の質問ではスキップ用に引き続き表示されます。",
|
||||
"auto_save_disabled": "自動保存が無効",
|
||||
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||
"auto_save_on": "自動保存オン",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "今四半期",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完了までの時間",
|
||||
"ttc_survey_tooltip": "アンケートの平均完了時間。",
|
||||
"ttc_tooltip": "フォームを完了するまでの平均時間。",
|
||||
"unknown_question_type": "不明な質問の種類",
|
||||
"use_personal_links": "個人リンクを使用",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Toewijzen =",
|
||||
"audience": "Publiek",
|
||||
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
|
||||
"auto_progress_rating_and_nps": "Automatisch doorgaan bij beoordelings- en NPS-vragen",
|
||||
"auto_progress_rating_and_nps_description": "Ga automatisch verder wanneer respondenten een antwoord selecteren bij beoordelings- of NPS-vragen. Dit geldt alleen voor blokken met één vraag. Bij verplichte vragen wordt de Volgende-knop verborgen; bij optionele vragen blijft deze zichtbaar om de vraag over te slaan.",
|
||||
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||
"auto_save_on": "Automatisch opslaan aan",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Dit kwartaal",
|
||||
"this_year": "Dit jaar",
|
||||
"time_to_complete": "Tijd om te voltooien",
|
||||
"ttc_survey_tooltip": "Gemiddelde tijd om de enquête te voltooien.",
|
||||
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
|
||||
"unknown_question_type": "Onbekend vraagtype",
|
||||
"use_personal_links": "Gebruik persoonlijke links",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de avaliação e NPS",
|
||||
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os respondentes selecionam uma resposta em perguntas de avaliação ou NPS. Isso se aplica apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Próximo; perguntas opcionais ainda o exibem para permitir pular.",
|
||||
"auto_save_disabled": "Salvamento automático desativado",
|
||||
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||
"auto_save_on": "Salvamento automático ativado",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_survey_tooltip": "Tempo médio para completar a pesquisa.",
|
||||
"ttc_tooltip": "Tempo médio para completar a pergunta.",
|
||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||
"use_personal_links": "Use links pessoais",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_progress_rating_and_nps": "Avançar automaticamente em perguntas de classificação e NPS",
|
||||
"auto_progress_rating_and_nps_description": "Avança automaticamente quando os inquiridos selecionam uma resposta em perguntas de classificação ou NPS. Isto aplica-se apenas a blocos com uma única pergunta. Perguntas obrigatórias ocultam o botão Seguinte; perguntas opcionais continuam a mostrá-lo para permitir saltar.",
|
||||
"auto_save_disabled": "Guardar automático desativado",
|
||||
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||
"auto_save_on": "Guardar automático ativado",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Este trimestre",
|
||||
"this_year": "Este ano",
|
||||
"time_to_complete": "Tempo para Concluir",
|
||||
"ttc_survey_tooltip": "Tempo médio para completar o inquérito.",
|
||||
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
|
||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||
"use_personal_links": "Utilize links pessoais",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Atribuire =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Închidere automată la inactivitate",
|
||||
"auto_progress_rating_and_nps": "Avansare automată pentru întrebări de rating și NPS",
|
||||
"auto_progress_rating_and_nps_description": "Avansează automat când respondenții selectează un răspuns la întrebările de rating sau NPS. Aceasta se aplică doar blocurilor cu o singură întrebare. Întrebările obligatorii ascund butonul Următorul; întrebările opționale îl afișează în continuare pentru a permite omiterea.",
|
||||
"auto_save_disabled": "Salvare automată dezactivată",
|
||||
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||
"auto_save_on": "Salvare automată activată",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Trimestrul acesta",
|
||||
"this_year": "Anul acesta",
|
||||
"time_to_complete": "Timp de finalizare",
|
||||
"ttc_survey_tooltip": "Timpul mediu de finalizare a sondajului.",
|
||||
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
|
||||
"unknown_question_type": "Tip de întrebare necunoscut",
|
||||
"use_personal_links": "Folosește linkuri personale",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Назначить =",
|
||||
"audience": "Аудитория",
|
||||
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
|
||||
"auto_progress_rating_and_nps": "Автоматический переход для вопросов с оценкой и NPS",
|
||||
"auto_progress_rating_and_nps_description": "Автоматически переходить к следующему шагу, когда респонденты выбирают ответ в вопросах с оценкой или NPS. Это применяется только к блокам с одним вопросом. В обязательных вопросах кнопка «Далее» скрыта; в необязательных вопросах она остается видимой для пропуска.",
|
||||
"auto_save_disabled": "Автосохранение отключено",
|
||||
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||
"auto_save_on": "Автосохранение включено",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "В этом квартале",
|
||||
"this_year": "В этом году",
|
||||
"time_to_complete": "Время на прохождение",
|
||||
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
|
||||
"ttc_tooltip": "Среднее время на ответ на вопрос.",
|
||||
"unknown_question_type": "Неизвестный тип вопроса",
|
||||
"use_personal_links": "Использовать персональные ссылки",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "Tilldela =",
|
||||
"audience": "Målgrupp",
|
||||
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
|
||||
"auto_progress_rating_and_nps": "Gå vidare automatiskt vid betygs- och NPS-frågor",
|
||||
"auto_progress_rating_and_nps_description": "Gå automatiskt vidare när respondenter väljer ett svar på betygs- eller NPS-frågor. Detta gäller endast block med en enda fråga. Obligatoriska frågor döljer Nästa-knappen; valfria frågor visar den fortfarande för att kunna hoppas över.",
|
||||
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||
"auto_save_on": "Automatisk sparning på",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "Detta kvartal",
|
||||
"this_year": "Detta år",
|
||||
"time_to_complete": "Tid att slutföra",
|
||||
"ttc_survey_tooltip": "Genomsnittlig tid för att slutföra enkäten.",
|
||||
"ttc_tooltip": "Genomsnittlig tid för att slutföra frågan.",
|
||||
"unknown_question_type": "Okänd frågetyp",
|
||||
"use_personal_links": "Använd personliga länkar",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "指派 =",
|
||||
"audience": "受众",
|
||||
"auto_close_on_inactivity": "自动关闭 在 无活动时",
|
||||
"auto_progress_rating_and_nps": "自动推进评分和 NPS 问题",
|
||||
"auto_progress_rating_and_nps_description": "当受访者在评分或 NPS 问题上选择答案时自动前进。这仅适用于单问题区块。必填问题会隐藏\"下一步\"按钮;可选问题仍会显示该按钮以便跳过。",
|
||||
"auto_save_disabled": "自动保存已禁用",
|
||||
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||
"auto_save_on": "自动保存已启用",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "本季度",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成时间",
|
||||
"ttc_survey_tooltip": "完成调查的平均时间。",
|
||||
"ttc_tooltip": "完成 本 问题 的 平均 时间",
|
||||
"unknown_question_type": "未知 问题 类型",
|
||||
"use_personal_links": "使用 个人 链接",
|
||||
|
||||
@@ -1359,6 +1359,8 @@
|
||||
"assign": "等於 =",
|
||||
"audience": "受眾",
|
||||
"auto_close_on_inactivity": "非活動時自動關閉",
|
||||
"auto_progress_rating_and_nps": "自動前進評分與 NPS 問題",
|
||||
"auto_progress_rating_and_nps_description": "當受訪者在評分或 NPS 問題中選擇答案時自動前進。此設定僅適用於單一問題區塊。必填問題會隱藏「下一步」按鈕;選填問題仍會顯示該按鈕以便跳過。",
|
||||
"auto_save_disabled": "自動儲存已停用",
|
||||
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||
"auto_save_on": "自動儲存已啟用",
|
||||
@@ -2127,6 +2129,7 @@
|
||||
"this_quarter": "本季",
|
||||
"this_year": "今年",
|
||||
"time_to_complete": "完成時間",
|
||||
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
|
||||
"ttc_tooltip": "完成 問題 的 平均 時間。",
|
||||
"unknown_question_type": "未知的問題類型",
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
|
||||
+6
-4
@@ -163,10 +163,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
||||
/>
|
||||
);
|
||||
} else if (Array.isArray(responseData)) {
|
||||
const itemsArray = responseData.map((choice) => {
|
||||
const choiceId = getChoiceIdByValue(choice, element);
|
||||
return { value: choice, id: choiceId };
|
||||
});
|
||||
const itemsArray = responseData
|
||||
.filter((choice) => choice !== "")
|
||||
.map((choice) => {
|
||||
const choiceId = getChoiceIdByValue(choice, element);
|
||||
return { value: choice, id: choiceId };
|
||||
});
|
||||
return (
|
||||
<>
|
||||
{element.type === TSurveyElementTypeEnum.Ranking ? (
|
||||
|
||||
+5
-8
@@ -98,14 +98,11 @@ describe("Users Lib", () => {
|
||||
|
||||
test("returns conflict error if user with email already exists", async () => {
|
||||
(prisma.user.create as any).mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError(
|
||||
"Unique constraint failed on the fields: (`email`)",
|
||||
{
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "1.0.0",
|
||||
meta: { target: ["email"] },
|
||||
}
|
||||
)
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed on the fields: (`email`)", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "1.0.0",
|
||||
meta: { target: ["email"] },
|
||||
})
|
||||
);
|
||||
const result = await createUser(
|
||||
{ name: "Duplicate", email: "test@example.com", role: "member" },
|
||||
|
||||
@@ -166,6 +166,11 @@ async function handleOrganizationCreation(ctx: ActionClientCtx, user: TCreatedUs
|
||||
});
|
||||
}
|
||||
|
||||
capturePostHogEvent(user.id, "organization_created", {
|
||||
organization_id: organization.id,
|
||||
is_first_org: true,
|
||||
});
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: {
|
||||
...user.notificationSettings,
|
||||
|
||||
@@ -46,9 +46,9 @@ export const OpenIdButton = ({
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="relative w-full justify-center">
|
||||
{text ? text : t("auth.continue_with_openid")}
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
className="w-full items-center justify-center gap-2 px-2">
|
||||
<span className="truncate">{text || t("auth.continue_with_openid")}</span>
|
||||
{lastUsed && <span className="shrink-0 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -118,6 +118,10 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
|
||||
const handleAutoProgressToggle = () => {
|
||||
setLocalSurvey({ ...localSurvey, isAutoProgressingEnabled: !localSurvey.isAutoProgressingEnabled });
|
||||
};
|
||||
|
||||
const handleCaptureIpToggle = () => {
|
||||
setCaptureIpToggle(!captureIpToggle);
|
||||
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
|
||||
@@ -384,6 +388,13 @@ export const ResponseOptionsCard = ({
|
||||
</AdvancedOptionToggle>
|
||||
</>
|
||||
)}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="autoProgressRatingNps"
|
||||
isChecked={Boolean(localSurvey.isAutoProgressingEnabled)}
|
||||
onToggle={handleAutoProgressToggle}
|
||||
title={t("environments.surveys.edit.auto_progress_rating_and_nps")}
|
||||
description={t("environments.surveys.edit.auto_progress_rating_and_nps_description")}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="hideBackButton"
|
||||
isChecked={localSurvey.isBackButtonHidden}
|
||||
|
||||
@@ -211,11 +211,15 @@ export const StylingView = ({
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<Switch checked={!!field.value} onCheckedChange={handleOverwriteToggle} />
|
||||
<Switch
|
||||
id="overwrite-theme-styling"
|
||||
checked={!!field.value}
|
||||
onCheckedChange={handleOverwriteToggle}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div>
|
||||
<FormLabel className="text-base font-semibold text-slate-900">
|
||||
<FormLabel htmlFor="overwrite-theme-styling" className="text-base font-semibold text-slate-900">
|
||||
{t("environments.surveys.edit.add_custom_styles")}
|
||||
</FormLabel>
|
||||
<FormDescription className="text-sm text-slate-800">
|
||||
|
||||
@@ -41,6 +41,7 @@ export const selectSurvey = {
|
||||
showLanguageSwitch: true,
|
||||
recaptcha: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
metadata: true,
|
||||
slug: true,
|
||||
customHeadScripts: true,
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("data", () => {
|
||||
redirectUrl: null,
|
||||
pin: null,
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
singleUse: null,
|
||||
projectOverwrites: null,
|
||||
styling: null,
|
||||
@@ -116,6 +117,11 @@ describe("data", () => {
|
||||
type: true,
|
||||
}),
|
||||
});
|
||||
expect(vi.mocked(prisma.survey.findUnique).mock.calls[0][0].select).toEqual(
|
||||
expect.objectContaining({
|
||||
isAutoProgressingEnabled: true,
|
||||
})
|
||||
);
|
||||
expect(transformPrismaSurvey).toHaveBeenCalledWith(mockSurveyData);
|
||||
});
|
||||
|
||||
@@ -196,6 +202,7 @@ describe("data", () => {
|
||||
redirectUrl: null,
|
||||
pin: null,
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
singleUse: null,
|
||||
projectOverwrites: null,
|
||||
surveyClosedMessage: null,
|
||||
|
||||
@@ -47,6 +47,7 @@ export const getSurveyWithMetadata = reactCache(async (surveyId: string) => {
|
||||
redirectUrl: true,
|
||||
pin: true,
|
||||
isBackButtonHidden: true,
|
||||
isAutoProgressingEnabled: true,
|
||||
isCaptureIpEnabled: true,
|
||||
|
||||
// Single use configuration
|
||||
|
||||
@@ -41,6 +41,7 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
|
||||
variables: [],
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: true,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
isCaptureIpEnabled: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Skeleton } from "@/modules/ui/components/skeleton";
|
||||
|
||||
type SkeletonLoaderProps = {
|
||||
type: "response" | "summary";
|
||||
type: "response" | "responseTable" | "summary";
|
||||
};
|
||||
|
||||
export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
|
||||
@@ -25,6 +25,43 @@ export const SkeletonLoader = ({ type }: SkeletonLoaderProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "responseTable") {
|
||||
const renderTableCells = () => (
|
||||
<>
|
||||
<Skeleton className="h-4 w-4 rounded-xl bg-slate-400" />
|
||||
<Skeleton className="h-4 w-24 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-40 rounded-xl bg-slate-200" />
|
||||
<Skeleton className="h-4 w-32 rounded-xl bg-slate-200" />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="animate-pulse space-y-4" data-testid="skeleton-loader-response-table">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-48 rounded-md bg-slate-300" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
|
||||
<Skeleton className="h-8 w-8 rounded-md bg-slate-300" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200">
|
||||
<div className="flex h-12 items-center gap-4 border-b border-slate-200 bg-slate-100 px-4">
|
||||
{renderTableCells()}
|
||||
</div>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex h-12 items-center gap-4 border-b border-slate-100 px-4 last:border-b-0">
|
||||
{renderTableCells()}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "response") {
|
||||
return (
|
||||
<div className="group space-y-4 rounded-lg bg-white p-6" data-testid="skeleton-loader-response">
|
||||
|
||||
@@ -5631,6 +5631,9 @@ components:
|
||||
isBackButtonHidden:
|
||||
type: boolean
|
||||
description: Whether the back button is hidden
|
||||
isAutoProgressingEnabled:
|
||||
type: boolean
|
||||
description: Whether auto-progress is enabled for eligible question types
|
||||
recaptcha:
|
||||
type:
|
||||
- object
|
||||
@@ -5706,6 +5709,7 @@ components:
|
||||
- isSingleResponsePerEmailEnabled
|
||||
- inlineTriggers
|
||||
- isBackButtonHidden
|
||||
- isAutoProgressingEnabled
|
||||
- recaptcha
|
||||
- metadata
|
||||
- displayPercentage
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
`#engineering-chat`
|
||||
|
||||
I’ve opened the Scope 1 v3 survey management implementation and RFC artifacts for review:
|
||||
|
||||
- OpenAPI: `docs/api-v3-reference/openapi.yml`
|
||||
- RFC: `docs/api-v3-reference/v3-survey-management-scope-1-rfc.md`
|
||||
|
||||
Main decisions:
|
||||
|
||||
- single-object survey create/update payloads instead of fragmented endpoints
|
||||
- strict scope limited to survey structure (`name`, `type`, `status`, `welcomeCard`, `blocks`, `endings`, `hiddenFields`, `variables`)
|
||||
- strict rejection of unsupported top-level fields
|
||||
- PATCH uses top-level partial updates with full subtree replacement for provided objects/arrays
|
||||
- DELETE returns `204 No Content`
|
||||
|
||||
Implementation follows the existing v3 conventions:
|
||||
|
||||
- session or `x-api-key`
|
||||
- RFC 9457 problem responses
|
||||
- `X-Request-Id`
|
||||
- `private, no-store`
|
||||
- shared wrapper/auth/error-reporting behavior
|
||||
|
||||
Focused tests for the new v3 surface are in place and the targeted coverage for the new `app/api/v3/lib` and `app/api/v3/surveys` surface is above 85%.
|
||||
@@ -1,52 +1,65 @@
|
||||
# V3 API — GET Surveys (hand-maintained; not generated by generate-api-specs).
|
||||
# Implementation: apps/web/app/api/v3/surveys/route.ts
|
||||
# See apps/web/app/api/v3/README.md and docs/Survey-Server-Actions.md (Part III) for full context.
|
||||
# V3 API — Survey Management
|
||||
# Implementation:
|
||||
# - apps/web/app/api/v3/surveys/route.ts
|
||||
# - apps/web/app/api/v3/surveys/[surveyId]/route.ts
|
||||
# - apps/web/app/api/v3/surveys/schemas.ts
|
||||
# - apps/web/app/api/v3/surveys/adapters.ts
|
||||
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: Formbricks API v3
|
||||
version: 0.2.0
|
||||
description: |
|
||||
**GET /api/v3/surveys** — authenticate with **session cookie** or **`x-api-key`** (management key with access to the workspace environment).
|
||||
Survey management endpoints for Formbricks' v3 API.
|
||||
|
||||
**Spec location:** `docs/api-v3-reference/openapi.yml` (alongside v2 at `docs/api-v2-reference/openapi.yml`).
|
||||
Key properties of this contract:
|
||||
- Authenticate with either a session cookie or `x-api-key`.
|
||||
- Use `workspaceId` externally; today this resolves to the underlying environment id.
|
||||
- Create and update surveys with a single strict survey object.
|
||||
- Reject unsupported top-level fields instead of silently ignoring them.
|
||||
- Return RFC 9457 problem responses for errors.
|
||||
|
||||
**workspaceId (today)**
|
||||
Query param `workspaceId` is the **environment id** (survey container in the DB). The API uses the name *workspace* because the product is moving toward **Workspace** as the default container; until that exists, resolution is implemented in `workspace-context.ts` (single place to change when Environment is deprecated).
|
||||
Scope 1 is intentionally limited to survey structure:
|
||||
- `name`
|
||||
- `type`
|
||||
- `status`
|
||||
- `welcomeCard`
|
||||
- `blocks`
|
||||
- `endings`
|
||||
- `hiddenFields`
|
||||
- `variables`
|
||||
|
||||
**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.
|
||||
|
||||
**Pagination**
|
||||
Cursor-based pagination with **limit** + opaque **cursor** token. Responses return `meta.nextCursor`; pass that value back as `cursor` to fetch the next page. Responses also include `meta.totalCount`, the total number of surveys matching the current filters across all pages. There is no `offset` in this contract.
|
||||
|
||||
**Filtering**
|
||||
Filters use explicit operator-style query parameters under the **`filter[...]` family**. This endpoint supports `filter[name][contains]`, `filter[status][in]`, and `filter[type][in]`. Multi-value filters use repeated keys or comma-separated values (e.g. `filter[status][in]=draft&filter[status][in]=inProgress` or `filter[status][in]=draft,inProgress`). Sorting remains a flat `sortBy` query parameter.
|
||||
|
||||
**Security**
|
||||
Missing/forbidden workspace returns **403** with a generic message (not **404**) so resource existence is not leaked. List responses use `private, no-store`.
|
||||
|
||||
**OpenAPI**
|
||||
This YAML is **not** produced by `pnpm generate-api-specs` (that script only builds v2 → `docs/api-v2-reference/openapi.yml`). Update this file when the route contract changes.
|
||||
|
||||
**Next steps (out of scope for this spec)**
|
||||
Additional v3 survey endpoints (single survey, CRUD), frontend cutover from `getSurveysAction`, optional ETag/304, field selection — see Survey-Server-Actions.md Part III.
|
||||
version: 0.1.0
|
||||
Out of scope and therefore rejected on create/update:
|
||||
- `questions`
|
||||
- distribution, targeting, and styling settings
|
||||
- follow-ups
|
||||
- recaptcha / spam protection
|
||||
- single-use or email-verification settings
|
||||
- languages, metadata, slug, and custom scripts
|
||||
x-implementation-notes:
|
||||
route: apps/web/app/api/v3/surveys/route.ts
|
||||
query-parser: apps/web/app/api/v3/surveys/parse-v3-surveys-list-query.ts
|
||||
route-collection: apps/web/app/api/v3/surveys/route.ts
|
||||
route-item: apps/web/app/api/v3/surveys/[surveyId]/route.ts
|
||||
auth: apps/web/app/api/v3/lib/auth.ts
|
||||
wrapper: apps/web/app/api/v3/lib/api-wrapper.ts
|
||||
workspace-resolution: apps/web/app/api/v3/lib/workspace-context.ts
|
||||
schema-source: apps/web/app/api/v3/surveys/schemas.ts
|
||||
adapter-source: apps/web/app/api/v3/surveys/adapters.ts
|
||||
openapi-generated: false
|
||||
pagination-model: cursor
|
||||
cursor-pagination: supported
|
||||
servers:
|
||||
- url: https://app.formbricks.com
|
||||
tags:
|
||||
- name: V3 Surveys
|
||||
paths:
|
||||
/api/v3/surveys:
|
||||
get:
|
||||
operationId: getSurveysV3
|
||||
operationId: listSurveysV3
|
||||
summary: List surveys
|
||||
description: Returns surveys for the workspace. Session cookie or x-api-key.
|
||||
tags:
|
||||
- V3 Surveys
|
||||
tags: [V3 Surveys]
|
||||
description: |
|
||||
Returns surveys for the given workspace.
|
||||
|
||||
Auth happens before query parsing. Unauthenticated callers receive `401`.
|
||||
Missing or unauthorized workspaces receive `403` to avoid leaking resource existence.
|
||||
parameters:
|
||||
- in: query
|
||||
name: workspaceId
|
||||
@@ -55,7 +68,7 @@ paths:
|
||||
type: string
|
||||
format: cuid2
|
||||
description: |
|
||||
Workspace identifier. **Today:** pass the **environment id** (the environment that contains the surveys). When Workspace replaces Environment in the data model, clients may pass workspace ids instead; resolution is centralized in workspace-context.
|
||||
Workspace identifier. Today this maps to the environment id used by the current data model.
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
@@ -63,19 +76,16 @@ paths:
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
description: Page size (max 100)
|
||||
- in: query
|
||||
name: cursor
|
||||
schema:
|
||||
type: string
|
||||
description: |
|
||||
Opaque cursor returned as `meta.nextCursor` from the previous page. Omit on the first request.
|
||||
description: Opaque cursor returned as `meta.nextCursor`.
|
||||
- in: query
|
||||
name: filter[name][contains]
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 512
|
||||
description: Case-insensitive substring match on survey name (same as in-app list filters).
|
||||
- in: query
|
||||
name: filter[status][in]
|
||||
schema:
|
||||
@@ -85,8 +95,6 @@ paths:
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
style: form
|
||||
explode: true
|
||||
description: |
|
||||
Survey status filter. Repeat the parameter (`filter[status][in]=draft&filter[status][in]=inProgress`) or use comma-separated values (`filter[status][in]=draft,inProgress`). Invalid values → **400**.
|
||||
- in: query
|
||||
name: filter[type][in]
|
||||
schema:
|
||||
@@ -96,23 +104,20 @@ paths:
|
||||
enum: [link, app]
|
||||
style: form
|
||||
explode: true
|
||||
description: Survey type filter (`link` / `app`). Same repeat-or-comma rules as `filter[status][in]`.
|
||||
- in: query
|
||||
name: sortBy
|
||||
schema:
|
||||
type: string
|
||||
enum: [createdAt, updatedAt, name, relevance]
|
||||
description: Sort order. Defaults to `updatedAt`. The `cursor` token is bound to the selected sort order.
|
||||
responses:
|
||||
"200":
|
||||
description: Surveys retrieved successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
description: Request correlation ID
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: "private, no-store"
|
||||
example: private, no-store
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -127,49 +132,221 @@ paths:
|
||||
type: object
|
||||
required: [limit, nextCursor, totalCount]
|
||||
properties:
|
||||
limit: { type: integer }
|
||||
limit:
|
||||
type: integer
|
||||
nextCursor:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Opaque cursor for the next page. `null` when there are no more results.
|
||||
totalCount:
|
||||
type: integer
|
||||
minimum: 0
|
||||
description: Total number of surveys matching the current filters across all pages.
|
||||
"400":
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
$ref: "#/components/responses/Problem400"
|
||||
"401":
|
||||
description: Not authenticated (no valid session or API key)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
$ref: "#/components/responses/Problem401"
|
||||
"403":
|
||||
description: Forbidden — no access, or workspace/environment does not exist (404 not used; avoids existence leak)
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
$ref: "#/components/responses/Problem403"
|
||||
"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"
|
||||
$ref: "#/components/responses/Problem429"
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
$ref: "#/components/responses/Problem500"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
post:
|
||||
operationId: createSurveyV3
|
||||
summary: Create a survey
|
||||
tags: [V3 Surveys]
|
||||
description: |
|
||||
Creates a survey from a single strict survey document.
|
||||
|
||||
Defaults applied by the API:
|
||||
- `type = "link"`
|
||||
- `status = "draft"`
|
||||
- `welcomeCard = { enabled: false }`
|
||||
- `endings = []`
|
||||
- `hiddenFields = { enabled: false }`
|
||||
- `variables = []`
|
||||
|
||||
`createdBy` is injected from the session user when available and remains `null` for API-key callers.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SurveyCreateRequest"
|
||||
examples:
|
||||
minimal:
|
||||
value:
|
||||
workspaceId: clxx1234567890123456789012
|
||||
name: Product Feedback Survey
|
||||
blocks:
|
||||
- id: clbk1234567890123456789012
|
||||
name: Intro
|
||||
elements:
|
||||
- id: satisfaction
|
||||
type: openText
|
||||
headline:
|
||||
default: What should we improve?
|
||||
required: true
|
||||
responses:
|
||||
"201":
|
||||
description: Survey created successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: private, no-store
|
||||
Location:
|
||||
schema: { type: string }
|
||||
example: /api/v3/surveys/clsv1234567890123456789012
|
||||
content:
|
||||
application/problem+json:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/SurveyResource"
|
||||
"400":
|
||||
$ref: "#/components/responses/Problem400"
|
||||
"401":
|
||||
$ref: "#/components/responses/Problem401"
|
||||
"403":
|
||||
$ref: "#/components/responses/Problem403"
|
||||
"429":
|
||||
$ref: "#/components/responses/Problem429"
|
||||
"500":
|
||||
$ref: "#/components/responses/Problem500"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
/api/v3/surveys/{surveyId}:
|
||||
get:
|
||||
operationId: getSurveyV3
|
||||
summary: Retrieve a survey
|
||||
tags: [V3 Surveys]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SurveyId"
|
||||
responses:
|
||||
"200":
|
||||
description: Survey retrieved successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: private, no-store
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/SurveyResource"
|
||||
"400":
|
||||
$ref: "#/components/responses/Problem400"
|
||||
"401":
|
||||
$ref: "#/components/responses/Problem401"
|
||||
"403":
|
||||
$ref: "#/components/responses/Problem403"
|
||||
"404":
|
||||
$ref: "#/components/responses/Problem404"
|
||||
"429":
|
||||
$ref: "#/components/responses/Problem429"
|
||||
"500":
|
||||
$ref: "#/components/responses/Problem500"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
patch:
|
||||
operationId: updateSurveyV3
|
||||
summary: Update a survey
|
||||
tags: [V3 Surveys]
|
||||
description: |
|
||||
Applies a strict top-level partial update.
|
||||
|
||||
Patch semantics:
|
||||
- omitted top-level fields are preserved
|
||||
- provided objects and arrays replace the entire subtree
|
||||
- immutable and out-of-scope top-level fields are rejected
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SurveyId"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SurveyPatchRequest"
|
||||
examples:
|
||||
rename:
|
||||
value:
|
||||
name: Updated Survey Name
|
||||
replaceWelcomeCard:
|
||||
value:
|
||||
welcomeCard:
|
||||
enabled: false
|
||||
responses:
|
||||
"200":
|
||||
description: Survey updated successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: private, no-store
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [data]
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/SurveyResource"
|
||||
"400":
|
||||
$ref: "#/components/responses/Problem400"
|
||||
"401":
|
||||
$ref: "#/components/responses/Problem401"
|
||||
"403":
|
||||
$ref: "#/components/responses/Problem403"
|
||||
"404":
|
||||
$ref: "#/components/responses/Problem404"
|
||||
"429":
|
||||
$ref: "#/components/responses/Problem429"
|
||||
"500":
|
||||
$ref: "#/components/responses/Problem500"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
delete:
|
||||
operationId: deleteSurveyV3
|
||||
summary: Delete a survey
|
||||
tags: [V3 Surveys]
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/SurveyId"
|
||||
responses:
|
||||
"204":
|
||||
description: Survey deleted successfully
|
||||
headers:
|
||||
X-Request-Id:
|
||||
schema: { type: string }
|
||||
Cache-Control:
|
||||
schema: { type: string }
|
||||
example: private, no-store
|
||||
"400":
|
||||
$ref: "#/components/responses/Problem400"
|
||||
"401":
|
||||
$ref: "#/components/responses/Problem401"
|
||||
"403":
|
||||
$ref: "#/components/responses/Problem403"
|
||||
"404":
|
||||
$ref: "#/components/responses/Problem404"
|
||||
"429":
|
||||
$ref: "#/components/responses/Problem429"
|
||||
"500":
|
||||
$ref: "#/components/responses/Problem500"
|
||||
security:
|
||||
- sessionAuth: []
|
||||
- apiKeyAuth: []
|
||||
@@ -181,51 +358,422 @@ components:
|
||||
in: cookie
|
||||
name: next-auth.session-token
|
||||
description: |
|
||||
NextAuth session JWT cookie. **Development:** often `next-auth.session-token`.
|
||||
**Production (HTTPS):** often `__Secure-next-auth.session-token`. Send the cookie your browser receives after sign-in.
|
||||
NextAuth session cookie. In production this may be `__Secure-next-auth.session-token`.
|
||||
apiKeyAuth:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: x-api-key
|
||||
description: |
|
||||
Management API key; must include **workspaceId** as an allowed environment with read, write, or manage permission.
|
||||
Management API key with workspace-scoped environment permissions.
|
||||
parameters:
|
||||
SurveyId:
|
||||
in: path
|
||||
name: surveyId
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: cuid2
|
||||
responses:
|
||||
Problem400:
|
||||
description: Bad Request
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
Problem401:
|
||||
description: Not authenticated
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
Problem403:
|
||||
description: Forbidden
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
Problem404:
|
||||
description: Not Found
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
Problem429:
|
||||
description: Rate limit exceeded
|
||||
headers:
|
||||
Retry-After:
|
||||
schema: { type: integer }
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
Problem500:
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/problem+json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Problem"
|
||||
schemas:
|
||||
SurveyListItem:
|
||||
type: object
|
||||
description: |
|
||||
Shape from `getSurveys` (`surveySelect` + `responseCount`). Serialized dates are ISO 8601 strings.
|
||||
Legacy DB rows may include survey **type** values `website` or `web` (see Prisma); filter **type** only accepts `link` | `app`.
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- workspaceId
|
||||
- name
|
||||
- type
|
||||
- status
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- responseCount
|
||||
- creator
|
||||
properties:
|
||||
id: { type: string }
|
||||
name: { type: string }
|
||||
environmentId: { type: string }
|
||||
type: { type: string, enum: [link, app, website, web] }
|
||||
id:
|
||||
type: string
|
||||
workspaceId:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [link, app, website, web]
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
responseCount: { type: integer }
|
||||
creator: { type: object, nullable: true, properties: { name: { type: string } } }
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
responseCount:
|
||||
type: integer
|
||||
creator:
|
||||
type: object
|
||||
nullable: true
|
||||
additionalProperties: false
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
SurveyCreateRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [workspaceId, name, blocks]
|
||||
properties:
|
||||
workspaceId:
|
||||
type: string
|
||||
format: cuid2
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
type:
|
||||
type: string
|
||||
enum: [link, app]
|
||||
default: link
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
default: draft
|
||||
welcomeCard:
|
||||
$ref: "#/components/schemas/SurveyWelcomeCard"
|
||||
blocks:
|
||||
type: array
|
||||
minItems: 1
|
||||
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"
|
||||
SurveyPatchRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
minProperties: 1
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
type:
|
||||
type: string
|
||||
enum: [link, app]
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
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"
|
||||
SurveyResource:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- id
|
||||
- workspaceId
|
||||
- createdAt
|
||||
- updatedAt
|
||||
- name
|
||||
- type
|
||||
- status
|
||||
- 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]
|
||||
status:
|
||||
type: string
|
||||
enum: [draft, inProgress, paused, completed]
|
||||
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"
|
||||
SurveyWelcomeCard:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
headline:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
fileUrl:
|
||||
type: string
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
timeToFinish:
|
||||
type: boolean
|
||||
default: true
|
||||
showResponseCount:
|
||||
type: boolean
|
||||
default: false
|
||||
videoUrl:
|
||||
type: string
|
||||
SurveyBlock:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, name, elements]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
name:
|
||||
type: string
|
||||
minLength: 1
|
||||
elements:
|
||||
type: array
|
||||
minItems: 1
|
||||
items:
|
||||
$ref: "#/components/schemas/SurveyElement"
|
||||
logic:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Block logic objects validated by the shared survey logic schema.
|
||||
logicFallback:
|
||||
type: string
|
||||
format: cuid2
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
backButtonLabel:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
SurveyElement:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [id, type, headline, required]
|
||||
description: |
|
||||
Shared survey element shape. Common keys are documented here; additional
|
||||
element-type-specific keys are validated by the shared Formbricks survey schema.
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
headline:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
required:
|
||||
type: boolean
|
||||
SurveyEnding:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SurveyEndScreenEnding"
|
||||
- $ref: "#/components/schemas/SurveyRedirectEnding"
|
||||
SurveyEndScreenEnding:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, type]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
type:
|
||||
type: string
|
||||
enum: [endScreen]
|
||||
headline:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
subheader:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
buttonLabel:
|
||||
$ref: "#/components/schemas/I18nString"
|
||||
buttonLink:
|
||||
type: string
|
||||
imageUrl:
|
||||
type: string
|
||||
videoUrl:
|
||||
type: string
|
||||
SurveyRedirectEnding:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, type]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
type:
|
||||
type: string
|
||||
enum: [redirectToUrl]
|
||||
url:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
SurveyHiddenFields:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [enabled]
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
fieldIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
SurveyVariable:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/NumberSurveyVariable"
|
||||
- $ref: "#/components/schemas/TextSurveyVariable"
|
||||
NumberSurveyVariable:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, name, type]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [number]
|
||||
value:
|
||||
type: number
|
||||
default: 0
|
||||
TextSurveyVariable:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [id, name, type]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: cuid2
|
||||
name:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: [text]
|
||||
value:
|
||||
type: string
|
||||
default: ""
|
||||
I18nString:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Language-keyed string object with a required `default` entry in practice.
|
||||
properties:
|
||||
default:
|
||||
type: string
|
||||
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`.
|
||||
required: [title, status, detail, requestId]
|
||||
properties:
|
||||
type: { type: string, format: uri }
|
||||
title: { type: string }
|
||||
status: { type: integer }
|
||||
detail: { type: string }
|
||||
instance: { type: string }
|
||||
type:
|
||||
type: string
|
||||
format: uri
|
||||
title:
|
||||
type: string
|
||||
status:
|
||||
type: integer
|
||||
detail:
|
||||
type: string
|
||||
instance:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
enum: [bad_request, not_authenticated, forbidden, internal_server_error, too_many_requests]
|
||||
requestId: { type: string }
|
||||
details: { type: object }
|
||||
enum:
|
||||
- bad_request
|
||||
- not_authenticated
|
||||
- forbidden
|
||||
- not_found
|
||||
- internal_server_error
|
||||
- too_many_requests
|
||||
requestId:
|
||||
type: string
|
||||
details:
|
||||
type: object
|
||||
invalid_params:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
name: { type: string }
|
||||
reason: { type: string }
|
||||
name:
|
||||
type: string
|
||||
reason:
|
||||
type: string
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "Survey"
|
||||
ADD COLUMN "isAutoProgressingEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -395,6 +395,7 @@ model Survey {
|
||||
isVerifyEmailEnabled Boolean @default(false)
|
||||
isSingleResponsePerEmailEnabled Boolean @default(false)
|
||||
isBackButtonHidden Boolean @default(false)
|
||||
isAutoProgressingEnabled Boolean @default(false)
|
||||
isCaptureIpEnabled Boolean @default(false)
|
||||
pin String?
|
||||
displayPercentage Decimal?
|
||||
|
||||
@@ -138,6 +138,7 @@ const ZSurveyBase = z.object({
|
||||
isSingleResponsePerEmailEnabled: z.boolean().describe("Whether single response per email is enabled"),
|
||||
inlineTriggers: z.array(z.any()).nullable().describe("Inline triggers configuration"),
|
||||
isBackButtonHidden: z.boolean().describe("Whether the back button is hidden"),
|
||||
isAutoProgressingEnabled: z.boolean().describe("Whether auto-progress is enabled for eligible questions"),
|
||||
recaptcha: ZSurveyRecaptcha.describe("Google reCAPTCHA configuration"),
|
||||
metadata: ZSurveyMetadata.describe("Custom link metadata for social sharing"),
|
||||
displayPercentage: z.number().nullable().describe("The display percentage of the survey"),
|
||||
|
||||
@@ -79,4 +79,5 @@ export const mockSurvey: TEnvironmentStateSurvey = {
|
||||
brandColor: { light: "#2B6CB0" },
|
||||
},
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export type TEnvironmentStateSurvey = Pick<
|
||||
| "delay"
|
||||
| "projectOverwrites"
|
||||
| "isBackButtonHidden"
|
||||
| "isAutoProgressingEnabled"
|
||||
| "recaptcha"
|
||||
> & {
|
||||
languages: (SurveyLanguage & { language: Language })[];
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the AWS SDK S3Client
|
||||
vi.mock("@aws-sdk/client-s3", () => ({
|
||||
S3Client: vi.fn(function MockS3Client(
|
||||
|
||||
@@ -11,6 +11,15 @@ import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
type Paginator<T> = AsyncGenerator<T, undefined, unknown>;
|
||||
|
||||
// Mock AWS SDK modules
|
||||
|
||||
@@ -280,6 +280,7 @@ function DropdownVariant({
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
dir={dir}
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
@@ -401,6 +402,7 @@ function ListVariant({
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
dir={dir}
|
||||
className="mt-2 w-full"
|
||||
ref={otherInputRef}
|
||||
|
||||
@@ -272,6 +272,7 @@ function SingleSelect({
|
||||
onChange={handleOtherInputChange}
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
dir={dir}
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
@@ -334,6 +335,7 @@ function SingleSelect({
|
||||
placeholder={otherOptionPlaceholder}
|
||||
disabled={disabled}
|
||||
aria-required={required}
|
||||
aria-invalid={Boolean(errorMessage)}
|
||||
dir={dir}
|
||||
className="mt-2 w-full"
|
||||
/>
|
||||
|
||||
@@ -4,17 +4,16 @@ import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20",
|
||||
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
custom: "button-custom",
|
||||
},
|
||||
|
||||
@@ -225,7 +225,7 @@ function CalendarDayButton({
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
"data-[selected-single=true]:bg-brand data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground hover:text-primary-foreground data-[selected-single=true]:hover:bg-brand data-[selected-single=true]:hover:text-primary-foreground data-[range-start=true]:hover:bg-primary data-[range-start=true]:hover:text-primary-foreground data-[range-end=true]:hover:bg-primary data-[range-end=true]:hover:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] hover:bg-[color-mix(in_srgb,var(--fb-survey-brand-color)_70%,transparent)] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ function Checkbox({
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"border-input-border dark:bg-input/30 data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground dark:data-[state=checked]:bg-brand data-[state=checked]:border-brand focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive text-input-text peer size-4 shrink-0 rounded-[4px] border bg-white shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"border-input-border data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground data-[state=checked]:border-brand focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive text-input-text peer size-4 shrink-0 rounded-[4px] border bg-white shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -58,7 +58,7 @@ function DropdownMenuItem({
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
// Focus ring
|
||||
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
|
||||
// Error state ring
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
// Disabled state
|
||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
|
||||
@@ -31,7 +31,7 @@ function RadioGroupItem({
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input-border text-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border bg-white shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"border-input-border text-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive aspect-square size-4 shrink-0 rounded-full border bg-white shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
|
||||
@@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
|
||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||
dir={dir}
|
||||
className={cn(
|
||||
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"w-input bg-input-bg border-input-border rounded-input font-input font-input-weight px-input-x py-input-y shadow-input placeholder:text-input-placeholder placeholder:opacity-input-placeholder focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 aria-invalid:border-destructive text-input text-input-text flex field-sizing-content min-h-16 border transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { cn } from "./utils";
|
||||
|
||||
vi.mock("isomorphic-dompurify", () => ({
|
||||
sanitize: vi.fn((value: string) => value),
|
||||
}));
|
||||
|
||||
describe("cn", () => {
|
||||
test("merges class names correctly", () => {
|
||||
expect(cn("foo", "bar")).toBe("foo bar");
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"da",
|
||||
"de",
|
||||
"es",
|
||||
"et",
|
||||
"fr",
|
||||
"hi",
|
||||
"hu",
|
||||
|
||||
@@ -34,6 +34,7 @@ checksums:
|
||||
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
|
||||
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
|
||||
common/welcome_video: 1f87e84c0a563c2522eef5cb71a1f95c
|
||||
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
|
||||
errors/all_options_must_be_ranked: 360a2edff623496f7047907bad115ea1
|
||||
errors/all_rows_must_be_answered: 295f41a0ef04cbb3491c798053c61abd
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "شروط الخدمة",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "لا يمكن الوصول إلى الخوادم في الوقت الحالي.",
|
||||
"they_will_be_redirected_immediately": "سيتم إعادة توجيههم فورًا",
|
||||
"welcome_video": "فيديو بطاقة الترحيب",
|
||||
"your_feedback_is_stuck": "تعليقك عالق :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Vilkår for brug",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Serverne kan ikke kontaktes lige nu.",
|
||||
"they_will_be_redirected_immediately": "De bliver straks omdirigeret",
|
||||
"welcome_video": "Velkomstkortvideo",
|
||||
"your_feedback_is_stuck": "Din feedback sidder fast :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
|
||||
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
|
||||
"welcome_video": "Willkommenskarten-Video",
|
||||
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Terms of Service",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
|
||||
"they_will_be_redirected_immediately": "They will be redirected immediately",
|
||||
"welcome_video": "Welcome Card video",
|
||||
"your_feedback_is_stuck": "Your feedback is stuck :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Términos de servicio",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Los servidores no pueden ser alcanzados en este momento.",
|
||||
"they_will_be_redirected_immediately": "Serán redirigidos inmediatamente",
|
||||
"welcome_video": "Vídeo de la tarjeta de bienvenida",
|
||||
"your_feedback_is_stuck": "Tu feedback está atascado :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Teenusetingimused",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
|
||||
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
|
||||
"welcome_video": "Tervituskaardi video",
|
||||
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Conditions d'utilisation",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Les serveurs ne sont pas accessibles pour le moment.",
|
||||
"they_will_be_redirected_immediately": "Ils seront redirigés immédiatement",
|
||||
"welcome_video": "Vidéo de la carte de bienvenue",
|
||||
"your_feedback_is_stuck": "Votre feedback est bloqué :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "सेवा की शर्तें",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "इस समय सर्वर तक पहुंचा नहीं जा सकता है।",
|
||||
"they_will_be_redirected_immediately": "उन्हें तुरंत रीडायरेक्ट किया जाएगा",
|
||||
"welcome_video": "स्वागत कार्ड वीडियो",
|
||||
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Használati feltételek",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Jelenleg nem lehet elérni a kiszolgálókat.",
|
||||
"they_will_be_redirected_immediately": "Azonnal át lesznek irányítva",
|
||||
"welcome_video": "Üdvözlő kártya videó",
|
||||
"your_feedback_is_stuck": "A visszajelzése elakadt :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Termini di servizio",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "I server non sono raggiungibili al momento.",
|
||||
"they_will_be_redirected_immediately": "Saranno reindirizzati immediatamente",
|
||||
"welcome_video": "Video della scheda di benvenuto",
|
||||
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "利用規約",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "現在サーバーに接続できません。",
|
||||
"they_will_be_redirected_immediately": "すぐにリダイレクトされます",
|
||||
"welcome_video": "ウェルカムカード動画",
|
||||
"your_feedback_is_stuck": "フィードバックが送信できません :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Servicevoorwaarden",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "De servers zijn momenteel niet bereikbaar.",
|
||||
"they_will_be_redirected_immediately": "Ze worden onmiddellijk doorgestuurd",
|
||||
"welcome_video": "Welkomstkaart video",
|
||||
"your_feedback_is_stuck": "Je feedback blijft hangen :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"terms_of_service": "Termos de serviço",
|
||||
"the_servers_cannot_be_reached_at_the_moment": "Os servidores não podem ser alcançados no momento.",
|
||||
"they_will_be_redirected_immediately": "Eles serão redirecionados imediatamente",
|
||||
"welcome_video": "Vídeo do Cartão de Boas-vindas",
|
||||
"your_feedback_is_stuck": "Seu feedback está preso :("
|
||||
},
|
||||
"errors": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user