mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-20 03:07:53 -05:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bad3b7a771 | |||
| 007d99f6b8 | |||
| 03b7dfefe4 | |||
| 9178558ba1 | |||
| a65e6d9093 | |||
| 592d36542f | |||
| 5ec8218666 | |||
| e1a44817f2 | |||
| 7f5b2bf69d | |||
| 60e7c7e8ee | |||
| 7988d7775c | |||
| b7ede6c578 | |||
| 8204a5c652 | |||
| e823e10f9a | |||
| f5c3212b2c | |||
| 2d66fc6987 | |||
| 652970003d | |||
| a8b5e286b6 |
+3
-1
@@ -107,7 +107,9 @@ export const SummaryMetadata = ({
|
|||||||
label={t("environments.surveys.summary.time_to_complete")}
|
label={t("environments.surveys.summary.time_to_complete")}
|
||||||
percentage={null}
|
percentage={null}
|
||||||
value={ttcAverage === 0 ? <span>-</span> : `${formatTime(ttcAverage)}`}
|
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}
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -164,7 +164,7 @@ describe("getSurveySummaryMeta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("calculates meta correctly", () => {
|
test("calculates meta correctly", () => {
|
||||||
const meta = getSurveySummaryMeta(mockResponses, 10, mockQuotas);
|
const meta = getSurveySummaryMeta(mockBaseSurvey, mockResponses, 10, mockQuotas);
|
||||||
expect(meta.displayCount).toBe(10);
|
expect(meta.displayCount).toBe(10);
|
||||||
expect(meta.totalResponses).toBe(3);
|
expect(meta.totalResponses).toBe(3);
|
||||||
expect(meta.startsPercentage).toBe(30);
|
expect(meta.startsPercentage).toBe(30);
|
||||||
@@ -178,13 +178,13 @@ describe("getSurveySummaryMeta", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero display count", () => {
|
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.startsPercentage).toBe(0);
|
||||||
expect(meta.completedPercentage).toBe(0);
|
expect(meta.completedPercentage).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("handles zero responses", () => {
|
test("handles zero responses", () => {
|
||||||
const meta = getSurveySummaryMeta([], 10, mockQuotas);
|
const meta = getSurveySummaryMeta(mockBaseSurvey, [], 10, mockQuotas);
|
||||||
expect(meta.totalResponses).toBe(0);
|
expect(meta.totalResponses).toBe(0);
|
||||||
expect(meta.completedResponses).toBe(0);
|
expect(meta.completedResponses).toBe(0);
|
||||||
expect(meta.dropOffCount).toBe(0);
|
expect(meta.dropOffCount).toBe(0);
|
||||||
@@ -274,7 +274,7 @@ describe("getSurveySummaryDropOff", () => {
|
|||||||
expect(dropOff[1].impressions).toBe(2);
|
expect(dropOff[1].impressions).toBe(2);
|
||||||
expect(dropOff[1].dropOffCount).toBe(1); // r1 dropped at q2 (last seen element)
|
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].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", () => {
|
test("drop-off attributed to last seen element when user doesn't reach next question", () => {
|
||||||
|
|||||||
+41
-8
@@ -51,7 +51,32 @@ interface TSurveySummaryResponse {
|
|||||||
finished: boolean;
|
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 = (
|
export const getSurveySummaryMeta = (
|
||||||
|
survey: TSurvey,
|
||||||
responses: TSurveySummaryResponse[],
|
responses: TSurveySummaryResponse[],
|
||||||
displayCount: number,
|
displayCount: number,
|
||||||
quotas: TSurveySummary["quotas"]
|
quotas: TSurveySummary["quotas"]
|
||||||
@@ -60,9 +85,15 @@ export const getSurveySummaryMeta = (
|
|||||||
|
|
||||||
let ttcResponseCount = 0;
|
let ttcResponseCount = 0;
|
||||||
const ttcSum = responses.reduce((acc, response) => {
|
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++;
|
ttcResponseCount++;
|
||||||
return acc + response.ttc._total;
|
return acc + responseTtcTotal;
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -117,12 +148,16 @@ export const getSurveySummaryDropOff = (
|
|||||||
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
let dropOffArr = new Array(elements.length).fill(0) as number[];
|
||||||
let impressionsArr = 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[];
|
let dropOffPercentageArr = new Array(elements.length).fill(0) as number[];
|
||||||
|
const elementIdToBlockId = getElementIdToBlockIdMap(survey);
|
||||||
|
|
||||||
responses.forEach((response) => {
|
responses.forEach((response) => {
|
||||||
// Calculate total time-to-completion per element
|
// Calculate total time-to-completion per element
|
||||||
|
const blockTimes = getBlockTimesForResponse(response, survey);
|
||||||
Object.keys(totalTtc).forEach((elementId) => {
|
Object.keys(totalTtc).forEach((elementId) => {
|
||||||
if (response.ttc && response.ttc[elementId]) {
|
const blockId = elementIdToBlockId[elementId];
|
||||||
totalTtc[elementId] += response.ttc[elementId];
|
const blockTtc = blockId ? (blockTimes[blockId] ?? 0) : 0;
|
||||||
|
if (blockTtc > 0) {
|
||||||
|
totalTtc[elementId] += blockTtc;
|
||||||
responseCounts[elementId]++;
|
responseCounts[elementId]++;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -974,10 +1009,8 @@ export const getSurveySummary = reactCache(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
const dropOff = getSurveySummaryDropOff(survey, elements, responses, displayCount);
|
||||||
const [meta, elementSummary] = await Promise.all([
|
const meta = getSurveySummaryMeta(survey, responses, displayCount, quotas);
|
||||||
getSurveySummaryMeta(responses, displayCount, quotas),
|
const elementSummary = await getElementSummary(survey, elements, responses, dropOff);
|
||||||
getElementSummary(survey, elements, responses, dropOff),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
|
|||||||
+4
@@ -18,6 +18,7 @@ interface AirtableWrapperProps {
|
|||||||
isEnabled: boolean;
|
isEnabled: boolean;
|
||||||
webAppUrl: string;
|
webAppUrl: string;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
|
showReconnectButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AirtableWrapper = ({
|
export const AirtableWrapper = ({
|
||||||
@@ -28,6 +29,7 @@ export const AirtableWrapper = ({
|
|||||||
isEnabled,
|
isEnabled,
|
||||||
webAppUrl,
|
webAppUrl,
|
||||||
locale,
|
locale,
|
||||||
|
showReconnectButton = false,
|
||||||
}: AirtableWrapperProps) => {
|
}: AirtableWrapperProps) => {
|
||||||
const [isConnected, setIsConnected] = useState(
|
const [isConnected, setIsConnected] = useState(
|
||||||
airtableIntegration ? airtableIntegration.config?.key : false
|
airtableIntegration ? airtableIntegration.config?.key : false
|
||||||
@@ -49,6 +51,8 @@ export const AirtableWrapper = ({
|
|||||||
setIsConnected={setIsConnected}
|
setIsConnected={setIsConnected}
|
||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
|
showReconnectButton={showReconnectButton}
|
||||||
|
handleAirtableAuthorization={handleAirtableAuthorization}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ConnectIntegration
|
<ConnectIntegration
|
||||||
|
|||||||
+38
-9
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Trash2Icon } from "lucide-react";
|
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -12,9 +12,11 @@ import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId
|
|||||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||||
import { IntegrationModalInputs } from "../lib/types";
|
import { IntegrationModalInputs } from "../lib/types";
|
||||||
|
|
||||||
interface ManageIntegrationProps {
|
interface ManageIntegrationProps {
|
||||||
@@ -24,10 +26,20 @@ interface ManageIntegrationProps {
|
|||||||
surveys: TSurvey[];
|
surveys: TSurvey[];
|
||||||
airtableArray: TIntegrationItem[];
|
airtableArray: TIntegrationItem[];
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
|
showReconnectButton: boolean;
|
||||||
|
handleAirtableAuthorization: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ManageIntegration = (props: ManageIntegrationProps) => {
|
export const ManageIntegration = ({
|
||||||
const { airtableIntegration, environmentId, setIsConnected, surveys, airtableArray } = props;
|
airtableIntegration,
|
||||||
|
environmentId,
|
||||||
|
setIsConnected,
|
||||||
|
surveys,
|
||||||
|
airtableArray,
|
||||||
|
showReconnectButton,
|
||||||
|
handleAirtableAuthorization,
|
||||||
|
locale,
|
||||||
|
}: ManageIntegrationProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
@@ -73,15 +85,34 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
: { isEditMode: false as const };
|
: { isEditMode: false as const };
|
||||||
return (
|
return (
|
||||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||||
<div className="flex w-full justify-end gap-x-6">
|
{showReconnectButton && (
|
||||||
<div className="flex items-center">
|
<Alert variant="warning" size="small" className="mb-4 w-full">
|
||||||
|
<AlertDescription>{t("environments.integrations.reconnect_button_description")}</AlertDescription>
|
||||||
|
<AlertButton onClick={handleAirtableAuthorization}>
|
||||||
|
{t("environments.integrations.reconnect_button")}
|
||||||
|
</AlertButton>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<div className="flex w-full justify-end space-x-2">
|
||||||
|
<div className="mr-6 flex items-center">
|
||||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||||
<span className="cursor-pointer text-slate-500">
|
<span className="text-slate-500">
|
||||||
{t("environments.integrations.connected_with_email", {
|
{t("environments.integrations.connected_with_email", {
|
||||||
email: airtableIntegration.config.email,
|
email: airtableIntegration.config.email,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button variant="outline" onClick={handleAirtableAuthorization}>
|
||||||
|
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||||
|
{t("environments.integrations.reconnect_button")}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t("environments.integrations.reconnect_button_tooltip")}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDefaultValues(null);
|
setDefaultValues(null);
|
||||||
@@ -122,9 +153,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
|||||||
<div className="col-span-2 text-center">{data.surveyName}</div>
|
<div className="col-span-2 text-center">{data.surveyName}</div>
|
||||||
<div className="col-span-2 text-center">{data.tableName}</div>
|
<div className="col-span-2 text-center">{data.tableName}</div>
|
||||||
<div className="col-span-2 text-center">{data.elements}</div>
|
<div className="col-span-2 text-center">{data.elements}</div>
|
||||||
<div className="col-span-2 text-center">
|
<div className="col-span-2 text-center">{timeSince(data.createdAt.toString(), locale)}</div>
|
||||||
{timeSince(data.createdAt.toString(), props.locale)}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+9
-1
@@ -1,4 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||||
@@ -31,8 +32,14 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let airtableArray: TIntegrationItem[] = [];
|
let airtableArray: TIntegrationItem[] = [];
|
||||||
|
let isTokenValid = true;
|
||||||
if (airtableIntegration?.config.key) {
|
if (airtableIntegration?.config.key) {
|
||||||
airtableArray = await getAirtableTables(params.environmentId);
|
try {
|
||||||
|
airtableArray = await getAirtableTables(params.environmentId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, "Failed to load Airtable bases — token may be expired or revoked");
|
||||||
|
isTokenValid = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isReadOnly) {
|
if (isReadOnly) {
|
||||||
return redirect("./");
|
return redirect("./");
|
||||||
@@ -51,6 +58,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
|||||||
surveys={surveys}
|
surveys={surveys}
|
||||||
webAppUrl={WEBAPP_URL}
|
webAppUrl={WEBAPP_URL}
|
||||||
locale={locale ?? DEFAULT_LOCALE}
|
locale={locale ?? DEFAULT_LOCALE}
|
||||||
|
showReconnectButton={!isTokenValid}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PageContentWrapper>
|
</PageContentWrapper>
|
||||||
|
|||||||
@@ -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 { PipelineTriggers, Webhook } from "@prisma/client";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { v7 as uuidv7 } from "uuid";
|
import { v7 as uuidv7 } from "uuid";
|
||||||
import { createCacheKey } from "@formbricks/cache";
|
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
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 { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||||
import { cache } from "@/lib/cache";
|
import { CRON_SECRET, DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
|
||||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
|
||||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||||
import { capturePostHogEvent } from "@/lib/posthog";
|
|
||||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||||
import { convertDatesInObject } from "@/lib/time";
|
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 { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||||
|
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
|
||||||
|
|
||||||
export const POST = async (request: Request) => {
|
export const POST = async (request: Request) => {
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
@@ -93,10 +91,15 @@ export const POST = async (request: Request) => {
|
|||||||
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
|
const webhooks: Webhook[] = await getWebhooksForPipeline(environmentId, event, surveyId);
|
||||||
// Prepare webhook and email promises
|
// Prepare webhook and email promises
|
||||||
|
|
||||||
// Fetch with timeout of 5 seconds to prevent hanging
|
// Fetch with timeout of 5 seconds to prevent hanging.
|
||||||
|
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
|
||||||
|
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
|
||||||
|
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
|
||||||
|
// pre-patch redirect-follow behavior for consistency.
|
||||||
|
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||||
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
|
||||||
return Promise.race([
|
return Promise.race([
|
||||||
fetch(url, options),
|
fetch(url, { ...options, redirect: redirectMode }),
|
||||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@@ -302,25 +305,16 @@ export const POST = async (request: Request) => {
|
|||||||
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
|
logger.error({ error, responseId: response.id }, "Failed to record response meter event");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sampled PostHog tracking: first response + every 100th
|
|
||||||
if (POSTHOG_KEY) {
|
if (POSTHOG_KEY) {
|
||||||
const responseCount = await cache.withCache(
|
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||||
() => getResponseCountBySurveyId(surveyId),
|
|
||||||
createCacheKey.response.countBySurveyId(surveyId),
|
|
||||||
60 * 1000
|
|
||||||
);
|
|
||||||
|
|
||||||
if (responseCount === 1 || responseCount % 100 === 0) {
|
captureSurveyResponsePostHogEvent({
|
||||||
capturePostHogEvent(organization.id, "survey_response_received", {
|
organizationId: organization.id,
|
||||||
survey_id: surveyId,
|
surveyId,
|
||||||
survey_type: survey.type,
|
surveyType: survey.type,
|
||||||
organization_id: organization.id,
|
environmentId,
|
||||||
environment_id: environmentId,
|
responseCount,
|
||||||
response_count: responseCount,
|
});
|
||||||
is_first_response: responseCount === 1,
|
|
||||||
milestone: responseCount === 1 ? "first" : String(responseCount),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send telemetry events
|
// Send telemetry events
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { google } from "googleapis";
|
import { google } from "googleapis";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,8 @@ import {
|
|||||||
} from "@/lib/constants";
|
} from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
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";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
|
|
||||||
export const GET = async (req: Request) => {
|
export const GET = async (req: Request) => {
|
||||||
@@ -82,6 +85,16 @@ export const GET = async (req: Request) => {
|
|||||||
|
|
||||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||||
if (result) {
|
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(
|
return Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||||
);
|
);
|
||||||
|
|||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { ZId } from "@formbricks/types/common";
|
||||||
|
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
|
|
||||||
|
export const getResponseIdByDisplayId = async (
|
||||||
|
environmentId: string,
|
||||||
|
displayId: string
|
||||||
|
): Promise<{ responseId: string | null }> => {
|
||||||
|
validateInputs([environmentId, ZId], [displayId, ZId]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const display = await prisma.display.findFirst({
|
||||||
|
where: {
|
||||||
|
id: displayId,
|
||||||
|
survey: {
|
||||||
|
environmentId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
response: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!display) {
|
||||||
|
throw new ResourceNotFoundError("Display", displayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
responseId: display.response?.id ?? null,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
throw new DatabaseError(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||||
|
import { responses } from "@/app/lib/api/response";
|
||||||
|
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
|
import { getResponseIdByDisplayId } from "./lib/response";
|
||||||
|
|
||||||
|
export const OPTIONS = async (): Promise<Response> => {
|
||||||
|
return responses.successResponse({}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const GET = withV1ApiWrapper({
|
||||||
|
handler: async ({
|
||||||
|
req,
|
||||||
|
props,
|
||||||
|
}: THandlerParams<{ params: Promise<{ environmentId: string; displayId: string }> }>) => {
|
||||||
|
const params = await props.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getResponseIdByDisplayId(params.environmentId, params.displayId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
response: responses.successResponse(response, true),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ResourceNotFoundError) {
|
||||||
|
return {
|
||||||
|
response: responses.notFoundResponse("Display", params.displayId, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
{ error, url: req.url, environmentId: params.environmentId, displayId: params.displayId },
|
||||||
|
"Error in GET /api/v1/client/[environmentId]/displays/[displayId]/response"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -5,7 +5,9 @@ import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
|||||||
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
import { fetchAirtableAuthToken } from "@/lib/airtable/service";
|
||||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
|
import { capturePostHogEvent } from "@/lib/posthog";
|
||||||
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
|
|
||||||
const getEmail = async (token: string) => {
|
const getEmail = async (token: string) => {
|
||||||
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
const req_ = await fetch("https://api.airtable.com/v0/meta/whoami", {
|
||||||
@@ -76,16 +78,31 @@ export const GET = withV1ApiWrapper({
|
|||||||
}
|
}
|
||||||
const email = await getEmail(key.access_token);
|
const email = await getEmail(key.access_token);
|
||||||
|
|
||||||
|
// Preserve existing integration data (survey-to-table mappings) when re-authorizing
|
||||||
|
const existingIntegration = await getIntegrationByType(environmentId, "airtable");
|
||||||
|
const existingData = existingIntegration?.config?.data ?? [];
|
||||||
|
|
||||||
const airtableIntegrationInput = {
|
const airtableIntegrationInput = {
|
||||||
type: "airtable" as "airtable",
|
type: "airtable" as "airtable",
|
||||||
environment: environmentId,
|
environment: environmentId,
|
||||||
config: {
|
config: {
|
||||||
key,
|
key,
|
||||||
data: [],
|
data: existingData,
|
||||||
email,
|
email,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
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 {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
import { getTables } from "@/lib/airtable/service";
|
import { getAirtableToken, getTables } from "@/lib/airtable/service";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ export const GET = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
|
const integration = await getIntegrationByType(environmentId, "airtable");
|
||||||
|
|
||||||
if (!integration) {
|
if (!integration) {
|
||||||
return {
|
return {
|
||||||
@@ -44,7 +43,12 @@ export const GET = withV1ApiWrapper({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tables = await getTables(integration.config.key, baseId.data);
|
// Use getAirtableToken to ensure the access token is refreshed if expired
|
||||||
|
const freshAccessToken = await getAirtableToken(environmentId);
|
||||||
|
const tables = await getTables(
|
||||||
|
{ ...integration.config.key, access_token: freshAccessToken },
|
||||||
|
baseId.data
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
response: responses.successResponse(tables),
|
response: responses.successResponse(tables),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
import { symmetricEncrypt } from "@/lib/crypto";
|
import { symmetricEncrypt } from "@/lib/crypto";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
|
import { capturePostHogEvent } from "@/lib/posthog";
|
||||||
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
handler: async ({ req, authentication }) => {
|
handler: async ({ req, authentication }) => {
|
||||||
@@ -96,6 +99,16 @@ export const GET = withV1ApiWrapper({
|
|||||||
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
const result = await createOrUpdateIntegration(environmentId, notionIntegration);
|
||||||
|
|
||||||
if (result) {
|
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 {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { logger } from "@formbricks/logger";
|
||||||
import {
|
import {
|
||||||
TIntegrationSlackConfig,
|
TIntegrationSlackConfig,
|
||||||
TIntegrationSlackConfigData,
|
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 { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||||
|
import { capturePostHogEvent } from "@/lib/posthog";
|
||||||
|
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||||
|
|
||||||
export const GET = withV1ApiWrapper({
|
export const GET = withV1ApiWrapper({
|
||||||
handler: async ({ req, authentication }) => {
|
handler: async ({ req, authentication }) => {
|
||||||
@@ -104,6 +107,16 @@ export const GET = withV1ApiWrapper({
|
|||||||
const result = await createOrUpdateIntegration(environmentId, integration);
|
const result = await createOrUpdateIntegration(environmentId, integration);
|
||||||
|
|
||||||
if (result) {
|
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 {
|
return {
|
||||||
response: Response.redirect(
|
response: Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/slack`
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import { prisma } from "@formbricks/database";
|
||||||
|
import { publicUserSelect } from "@/lib/user/public-user";
|
||||||
|
import { GET } from "./route";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
headers: vi.fn(),
|
||||||
|
getSessionUser: vi.fn(),
|
||||||
|
parseApiKeyV2: vi.fn(),
|
||||||
|
hashSha256: vi.fn(),
|
||||||
|
verifySecret: vi.fn(),
|
||||||
|
applyRateLimit: vi.fn(),
|
||||||
|
notAuthenticatedResponse: vi.fn(
|
||||||
|
() => new Response(JSON.stringify({ message: "Not authenticated" }), { status: 401 })
|
||||||
|
),
|
||||||
|
tooManyRequestsResponse: vi.fn(
|
||||||
|
(message: string) => new Response(JSON.stringify({ message }), { status: 429 })
|
||||||
|
),
|
||||||
|
badRequestResponse: vi.fn((message: string) => new Response(JSON.stringify({ message }), { status: 400 })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/headers", () => ({
|
||||||
|
headers: mocks.headers,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@formbricks/database", () => ({
|
||||||
|
prisma: {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
apiKey: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
|
||||||
|
getSessionUser: mocks.getSessionUser,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/lib/api/response", () => ({
|
||||||
|
responses: {
|
||||||
|
notAuthenticatedResponse: mocks.notAuthenticatedResponse,
|
||||||
|
tooManyRequestsResponse: mocks.tooManyRequestsResponse,
|
||||||
|
badRequestResponse: mocks.badRequestResponse,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/crypto", () => ({
|
||||||
|
hashSha256: mocks.hashSha256,
|
||||||
|
parseApiKeyV2: mocks.parseApiKeyV2,
|
||||||
|
verifySecret: mocks.verifySecret,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||||
|
applyRateLimit: mocks.applyRateLimit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||||
|
rateLimitConfigs: {
|
||||||
|
api: {
|
||||||
|
v1: { windowMs: 60_000, max: 1000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getMockHeaders = (apiKey: string | null) => ({
|
||||||
|
get: (headerName: string) => (headerName === "x-api-key" ? apiKey : null),
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("v1 management me route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.headers.mockResolvedValue(getMockHeaders(null));
|
||||||
|
mocks.getSessionUser.mockResolvedValue(undefined);
|
||||||
|
mocks.parseApiKeyV2.mockReturnValue(null);
|
||||||
|
mocks.hashSha256.mockReturnValue("hashed-api-key");
|
||||||
|
mocks.verifySecret.mockResolvedValue(false);
|
||||||
|
mocks.applyRateLimit.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns a sanitized authenticated user for session-based requests", async () => {
|
||||||
|
const publicUser = {
|
||||||
|
id: "user_123",
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
emailVerified: new Date("2025-04-17T20:11:54.947Z"),
|
||||||
|
createdAt: new Date("2025-04-17T20:09:14.021Z"),
|
||||||
|
updatedAt: new Date("2026-04-22T22:12:39.104Z"),
|
||||||
|
twoFactorEnabled: false,
|
||||||
|
identityProvider: "email" as const,
|
||||||
|
notificationSettings: {
|
||||||
|
alert: {},
|
||||||
|
unsubscribedOrganizationIds: [],
|
||||||
|
},
|
||||||
|
locale: "en-US" as const,
|
||||||
|
lastLoginAt: new Date("2026-04-22T22:12:39.104Z"),
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
mocks.getSessionUser.mockResolvedValue({ id: publicUser.id });
|
||||||
|
vi.mocked(prisma.user.findUnique).mockResolvedValue(publicUser as never);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const responseBody = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(responseBody).toStrictEqual(JSON.parse(JSON.stringify(publicUser)));
|
||||||
|
expect(responseBody).not.toHaveProperty("password");
|
||||||
|
expect(responseBody).not.toHaveProperty("twoFactorSecret");
|
||||||
|
expect(responseBody).not.toHaveProperty("backupCodes");
|
||||||
|
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
|
||||||
|
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: { id: publicUser.id },
|
||||||
|
select: publicUserSelect,
|
||||||
|
});
|
||||||
|
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), publicUser.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns the existing unauthenticated response when no session is present", async () => {
|
||||||
|
const response = await GET();
|
||||||
|
const responseBody = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(responseBody).toEqual({ message: "Not authenticated" });
|
||||||
|
expect(mocks.notAuthenticatedResponse).toHaveBeenCalled();
|
||||||
|
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves the API key response path", async () => {
|
||||||
|
const apiKeyData = {
|
||||||
|
id: "api_key_123",
|
||||||
|
organizationId: "org_123",
|
||||||
|
hashedKey: "stored-hash",
|
||||||
|
lastUsedAt: new Date(),
|
||||||
|
apiKeyEnvironments: [
|
||||||
|
{
|
||||||
|
permission: "manage",
|
||||||
|
environment: {
|
||||||
|
id: "env_123",
|
||||||
|
type: "development",
|
||||||
|
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||||
|
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||||
|
projectId: "project_123",
|
||||||
|
appSetupCompleted: true,
|
||||||
|
project: {
|
||||||
|
id: "project_123",
|
||||||
|
name: "My Project",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
mocks.headers.mockResolvedValue(getMockHeaders("api-key"));
|
||||||
|
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(apiKeyData as never);
|
||||||
|
|
||||||
|
const response = await GET();
|
||||||
|
const responseBody = await response.json();
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(responseBody).toStrictEqual({
|
||||||
|
id: "env_123",
|
||||||
|
type: "development",
|
||||||
|
createdAt: "2025-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||||
|
appSetupCompleted: true,
|
||||||
|
project: {
|
||||||
|
id: "project_123",
|
||||||
|
name: "My Project",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(mocks.getSessionUser).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), apiKeyData.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
|||||||
import { responses } from "@/app/lib/api/response";
|
import { responses } from "@/app/lib/api/response";
|
||||||
import { CONTROL_HASH } from "@/lib/constants";
|
import { CONTROL_HASH } from "@/lib/constants";
|
||||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||||
|
import { publicUserSelect } from "@/lib/user/public-user";
|
||||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||||
|
|
||||||
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: sessionUser.id },
|
where: { id: sessionUser.id },
|
||||||
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
return Response.json(user);
|
return Response.json(user);
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ checksums:
|
|||||||
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
|
auth/login/login_with_email: 4198b691f5d2bf2f443a03cc9fffd17f
|
||||||
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
|
auth/login/lost_access: 917c4665b99c37377ed522ba53249006
|
||||||
auth/login/new_to_formbricks: 1a1d45aca05bb21eb8f795d7d62dc4e3
|
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/login/use_a_backup_code: 181e4ab6ba9e5b063b46925f1925eb2b
|
||||||
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
|
auth/saml_connection_error: 03c69c534e7eaafcb2c22b7daf9f3efc
|
||||||
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
|
auth/signup/captcha_failed: 4e1ed87800585b8c1da1514fa86ab943
|
||||||
@@ -411,6 +413,7 @@ checksums:
|
|||||||
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
common/team_name: 549d949de4b9adad4afd6427a60a329e
|
||||||
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
common/team_role: 66db395781aef64ef3791417b3b67c0b
|
||||||
common/teams: b63448c05270497973ac4407047dae02
|
common/teams: b63448c05270497973ac4407047dae02
|
||||||
|
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||||
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
common/text: 4ddccc1974775ed7357f9beaf9361cec
|
||||||
common/time: b504a03d52e8001bfdc5cb6205364f42
|
common/time: b504a03d52e8001bfdc5cb6205364f42
|
||||||
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
|
common/time_to_finish: c8f6abdb886bee3619bb50b08fada5fa
|
||||||
@@ -781,6 +784,9 @@ checksums:
|
|||||||
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
|
environments/integrations/notion/update_connection_tooltip: 2429919f575e47f5c76e54b4442ba706
|
||||||
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
|
environments/integrations/notion_integration_description: 31a73dbe88fe18a078d6dc15f0c303e2
|
||||||
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
|
environments/integrations/please_select_a_survey_error: 465aa7048773079c8ffdde8b333b78eb
|
||||||
|
environments/integrations/reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||||
|
environments/integrations/reconnect_button_description: 01f79dc561ff87b5f2a80bf66e492844
|
||||||
|
environments/integrations/reconnect_button_tooltip: 5552effda9df8d6778dda1cf42e5d880
|
||||||
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
|
environments/integrations/select_at_least_one_question_error: a3513cb02ab0de2a1531893ac0c7e089
|
||||||
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
|
environments/integrations/slack/already_connected_another_survey: 4508f9e4a2915e3818ea5f9e2695e000
|
||||||
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
|
environments/integrations/slack/channel_name: 1afcd1d0401850ff353f5ae27502b04a
|
||||||
@@ -2021,6 +2027,7 @@ checksums:
|
|||||||
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||||
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||||
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
|
environments/surveys/summary/time_to_complete: ac14edd54df964d2d5ae07b97ae4091f
|
||||||
|
environments/surveys/summary/ttc_survey_tooltip: 9bd3971cb94670c54d74a4e86ee53172
|
||||||
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
environments/surveys/summary/ttc_tooltip: 9b1cbe32cc81111314bd3b6fd050c2e7
|
||||||
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
environments/surveys/summary/unknown_question_type: e4152a7457d2b94f48dcc70aaba9922f
|
||||||
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
environments/surveys/summary/use_personal_links: da2b3e7e1aaf2ea2bd4efed2dda4247c
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { logger } from "@formbricks/logger";
|
|||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import {
|
import {
|
||||||
TIntegrationAirtable,
|
|
||||||
TIntegrationAirtableConfigData,
|
TIntegrationAirtableConfigData,
|
||||||
TIntegrationAirtableCredential,
|
TIntegrationAirtableCredential,
|
||||||
ZIntegrationAirtableBases,
|
ZIntegrationAirtableBases,
|
||||||
@@ -24,6 +23,11 @@ export const getBases = async (key: string) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!req.ok) {
|
||||||
|
const body = await req.text().catch(() => "");
|
||||||
|
throw new Error(`Airtable API error fetching bases: ${req.status} ${req.statusText} ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await req.json();
|
const res = await req.json();
|
||||||
return ZIntegrationAirtableBases.parse(res);
|
return ZIntegrationAirtableBases.parse(res);
|
||||||
};
|
};
|
||||||
@@ -35,6 +39,11 @@ const tableFetcher = async (key: TIntegrationAirtableCredential, baseId: string)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!req.ok) {
|
||||||
|
const body = await req.text().catch(() => "");
|
||||||
|
throw new Error(`Airtable API error fetching tables: ${req.status} ${req.statusText} ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await req.json();
|
const res = await req.json();
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
@@ -78,10 +87,7 @@ export const fetchAirtableAuthToken = async (formData: Record<string, any>) => {
|
|||||||
|
|
||||||
export const getAirtableToken = async (environmentId: string) => {
|
export const getAirtableToken = async (environmentId: string) => {
|
||||||
try {
|
try {
|
||||||
const airtableIntegration = (await getIntegrationByType(
|
const airtableIntegration = await getIntegrationByType(environmentId, "airtable");
|
||||||
environmentId,
|
|
||||||
"airtable"
|
|
||||||
)) as TIntegrationAirtable;
|
|
||||||
|
|
||||||
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
|
const { access_token, expiry_date, refresh_token } = ZIntegrationAirtableCredential.parse(
|
||||||
airtableIntegration?.config.key
|
airtableIntegration?.config.key
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import { prisma } from "@formbricks/database";
|
|||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
import { ZId, ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||||
import { DatabaseError } from "@formbricks/types/errors";
|
import { DatabaseError } from "@formbricks/types/errors";
|
||||||
import { TIntegration, TIntegrationInput, ZIntegrationType } from "@formbricks/types/integration";
|
import {
|
||||||
|
TIntegration,
|
||||||
|
TIntegrationByType,
|
||||||
|
TIntegrationInput,
|
||||||
|
ZIntegrationType,
|
||||||
|
} from "@formbricks/types/integration";
|
||||||
import { ITEMS_PER_PAGE } from "../constants";
|
import { ITEMS_PER_PAGE } from "../constants";
|
||||||
import { validateInputs } from "../utils/validate";
|
import { validateInputs } from "../utils/validate";
|
||||||
|
|
||||||
@@ -94,7 +99,10 @@ export const getIntegration = reactCache(async (integrationId: string): Promise<
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const getIntegrationByType = reactCache(
|
export const getIntegrationByType = reactCache(
|
||||||
async (environmentId: string, type: TIntegrationInput["type"]): Promise<TIntegration | null> => {
|
async <T extends TIntegrationInput["type"]>(
|
||||||
|
environmentId: string,
|
||||||
|
type: T
|
||||||
|
): Promise<TIntegrationByType<T> | null> => {
|
||||||
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
|
validateInputs([environmentId, ZId], [type, ZIntegrationType]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -106,7 +114,7 @@ export const getIntegrationByType = reactCache(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return integration ? transformIntegration(integration) : null;
|
return integration ? (transformIntegration(integration) as TIntegrationByType<T>) : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
throw new DatabaseError(error.message);
|
throw new DatabaseError(error.message);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { TIntegrationNotionConfig, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||||
TIntegrationNotion,
|
|
||||||
TIntegrationNotionConfig,
|
|
||||||
TIntegrationNotionDatabase,
|
|
||||||
} from "@formbricks/types/integration/notion";
|
|
||||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||||
import { symmetricDecrypt } from "@/lib/crypto";
|
import { symmetricDecrypt } from "@/lib/crypto";
|
||||||
import { getIntegrationByType } from "../integration/service";
|
import { getIntegrationByType } from "../integration/service";
|
||||||
@@ -29,7 +25,7 @@ const fetchPages = async (config: TIntegrationNotionConfig) => {
|
|||||||
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
|
export const getNotionDatabases = async (environmentId: string): Promise<TIntegrationNotionDatabase[]> => {
|
||||||
let results: TIntegrationNotionDatabase[] = [];
|
let results: TIntegrationNotionDatabase[] = [];
|
||||||
try {
|
try {
|
||||||
const notionIntegration = (await getIntegrationByType(environmentId, "notion")) as TIntegrationNotion;
|
const notionIntegration = await getIntegrationByType(environmentId, "notion");
|
||||||
if (notionIntegration && notionIntegration.config?.key.bot_id) {
|
if (notionIntegration && notionIntegration.config?.key.bot_id) {
|
||||||
results = await fetchPages(notionIntegration.config);
|
results = await fetchPages(notionIntegration.config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ export const extractChoiceIdsFromResponse = (
|
|||||||
|
|
||||||
if (Array.isArray(responseValue)) {
|
if (Array.isArray(responseValue)) {
|
||||||
// Multiple choice case - response is an array of selected choice labels
|
// 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") {
|
} else if (typeof responseValue === "string") {
|
||||||
// Single choice case - response is a single choice label
|
// Single choice case - response is a single choice label
|
||||||
const choiceId = findChoiceByLabel(responseValue);
|
const choiceId = findChoiceByLabel(responseValue);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||||
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegration, TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationSlack, TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlackCredential } from "@formbricks/types/integration/slack";
|
||||||
import { SLACK_MESSAGE_LIMIT } from "../constants";
|
import { SLACK_MESSAGE_LIMIT } from "../constants";
|
||||||
import { deleteIntegration, getIntegrationByType } from "../integration/service";
|
import { deleteIntegration, getIntegrationByType } from "../integration/service";
|
||||||
import { truncateText } from "../utils/strings";
|
import { truncateText } from "../utils/strings";
|
||||||
@@ -58,7 +58,7 @@ export const fetchChannels = async (slackIntegration: TIntegration): Promise<TIn
|
|||||||
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
|
export const getSlackChannels = async (environmentId: string): Promise<TIntegrationItem[]> => {
|
||||||
let channels: TIntegrationItem[] = [];
|
let channels: TIntegrationItem[] = [];
|
||||||
try {
|
try {
|
||||||
const slackIntegration = (await getIntegrationByType(environmentId, "slack")) as TIntegrationSlack;
|
const slackIntegration = await getIntegrationByType(environmentId, "slack");
|
||||||
if (slackIntegration && slackIntegration.config?.key) {
|
if (slackIntegration && slackIntegration.config?.key) {
|
||||||
channels = await fetchChannels(slackIntegration);
|
channels = await fetchChannels(slackIntegration);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export const publicUserSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
twoFactorEnabled: true,
|
||||||
|
identityProvider: true,
|
||||||
|
notificationSettings: true,
|
||||||
|
locale: true,
|
||||||
|
lastLoginAt: true,
|
||||||
|
isActive: true,
|
||||||
|
} as const satisfies Prisma.UserSelect;
|
||||||
|
|
||||||
|
export type TPublicUser = Prisma.UserGetPayload<{
|
||||||
|
select: typeof publicUserSelect;
|
||||||
|
}>;
|
||||||
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
|||||||
import { TOrganization } from "@formbricks/types/organizations";
|
import { TOrganization } from "@formbricks/types/organizations";
|
||||||
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
|
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
|
||||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||||
|
import { publicUserSelect } from "./public-user";
|
||||||
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
|
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
|
||||||
|
|
||||||
vi.mock("@formbricks/database", () => ({
|
vi.mock("@formbricks/database", () => ({
|
||||||
@@ -47,11 +48,6 @@ describe("User Service", () => {
|
|||||||
locale: "en-US" as TUserLocale,
|
locale: "en-US" as TUserLocale,
|
||||||
lastLoginAt: new Date(),
|
lastLoginAt: new Date(),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
twoFactorSecret: null,
|
|
||||||
backupCodes: null,
|
|
||||||
password: null,
|
|
||||||
identityProviderAccountId: null,
|
|
||||||
groupId: null,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockOrganizations: TOrganization[] = [
|
const mockOrganizations: TOrganization[] = [
|
||||||
@@ -102,8 +98,12 @@ describe("User Service", () => {
|
|||||||
expect(result).toEqual(mockPrismaUser);
|
expect(result).toEqual(mockPrismaUser);
|
||||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||||
where: { id: "user1" },
|
where: { id: "user1" },
|
||||||
select: expect.any(Object),
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
|
expect(result).not.toHaveProperty("password");
|
||||||
|
expect(result).not.toHaveProperty("twoFactorSecret");
|
||||||
|
expect(result).not.toHaveProperty("backupCodes");
|
||||||
|
expect(result).not.toHaveProperty("identityProviderAccountId");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should return null when user not found", async () => {
|
test("should return null when user not found", async () => {
|
||||||
@@ -134,7 +134,7 @@ describe("User Service", () => {
|
|||||||
expect(result).toEqual(mockPrismaUser);
|
expect(result).toEqual(mockPrismaUser);
|
||||||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||||||
where: { email: "test@example.com" },
|
where: { email: "test@example.com" },
|
||||||
select: expect.any(Object),
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ describe("User Service", () => {
|
|||||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||||
where: { id: "user1" },
|
where: { id: "user1" },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
select: expect.any(Object),
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ describe("User Service", () => {
|
|||||||
expect(deleteOrganization).toHaveBeenCalledWith("org1");
|
expect(deleteOrganization).toHaveBeenCalledWith("org1");
|
||||||
expect(prisma.user.delete).toHaveBeenCalledWith({
|
expect(prisma.user.delete).toHaveBeenCalledWith({
|
||||||
where: { id: "user1" },
|
where: { id: "user1" },
|
||||||
select: expect.any(Object),
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ describe("User Service", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: expect.any(Object),
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
|
|||||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||||
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
|
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
|
||||||
import { validateInputs } from "../utils/validate";
|
import { validateInputs } from "../utils/validate";
|
||||||
|
import { publicUserSelect } from "./public-user";
|
||||||
const responseSelection = {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
email: true,
|
|
||||||
emailVerified: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
twoFactorEnabled: true,
|
|
||||||
identityProvider: true,
|
|
||||||
notificationSettings: true,
|
|
||||||
locale: true,
|
|
||||||
lastLoginAt: true,
|
|
||||||
isActive: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
// function to retrive basic information about a user's user
|
// function to retrive basic information about a user's user
|
||||||
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||||
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
|||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
select: responseSelection,
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
|
|||||||
where: {
|
where: {
|
||||||
email,
|
email,
|
||||||
},
|
},
|
||||||
select: responseSelection,
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
|
|||||||
id: personId,
|
id: personId,
|
||||||
},
|
},
|
||||||
data: data,
|
data: data,
|
||||||
select: responseSelection,
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedUser;
|
return updatedUser;
|
||||||
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
|
|||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
select: responseSelection,
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
return user;
|
return user;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: responseSelection,
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
|
|||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
select: responseSelection,
|
select: publicUserSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
|
"notion_integration_description": "Sende Daten an deine Notion Datenbank",
|
||||||
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
|
"please_select_a_survey_error": "Bitte wähle eine Umfrage aus",
|
||||||
|
"reconnect_button": "Erneut verbinden",
|
||||||
|
"reconnect_button_description": "Deine Integrationsverbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Links und Daten bleiben erhalten.",
|
||||||
|
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Links und Daten bleiben erhalten.",
|
||||||
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
|
"select_at_least_one_question_error": "Bitte wähle mindestens eine Frage aus",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
|
"already_connected_another_survey": "Du hast bereits eine andere Umfrage mit diesem Kanal verbunden.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Dieses Quartal",
|
"this_quarter": "Dieses Quartal",
|
||||||
"this_year": "Dieses Jahr",
|
"this_year": "Dieses Jahr",
|
||||||
"time_to_complete": "Zeit zur Fertigstellung",
|
"time_to_complete": "Zeit zur Fertigstellung",
|
||||||
|
"ttc_survey_tooltip": "Durchschnittliche Zeit zum Abschließen der Umfrage.",
|
||||||
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
|
"ttc_tooltip": "Durchschnittliche Zeit zum Beantworten der Frage.",
|
||||||
"unknown_question_type": "Unbekannter Fragetyp",
|
"unknown_question_type": "Unbekannter Fragetyp",
|
||||||
"use_personal_links": "Nutze persönliche Links",
|
"use_personal_links": "Nutze persönliche Links",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Send data to your Notion database",
|
"notion_integration_description": "Send data to your Notion database",
|
||||||
"please_select_a_survey_error": "Please select a survey",
|
"please_select_a_survey_error": "Please select a survey",
|
||||||
|
"reconnect_button": "Reconnect",
|
||||||
|
"reconnect_button_description": "Your integration connection has expired. Please reconnect to continue syncing responses. Your existing links and data will be preserved.",
|
||||||
|
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing links and data will be preserved.",
|
||||||
"select_at_least_one_question_error": "Please select at least one question",
|
"select_at_least_one_question_error": "Please select at least one question",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "You have already connected another survey to this channel.",
|
"already_connected_another_survey": "You have already connected another survey to this channel.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "This quarter",
|
"this_quarter": "This quarter",
|
||||||
"this_year": "This year",
|
"this_year": "This year",
|
||||||
"time_to_complete": "Time to Complete",
|
"time_to_complete": "Time to Complete",
|
||||||
|
"ttc_survey_tooltip": "Average time to complete the survey.",
|
||||||
"ttc_tooltip": "Average time to complete the question.",
|
"ttc_tooltip": "Average time to complete the question.",
|
||||||
"unknown_question_type": "Unknown Question Type",
|
"unknown_question_type": "Unknown Question Type",
|
||||||
"use_personal_links": "Use personal links",
|
"use_personal_links": "Use personal links",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Envía datos a tu base de datos de Notion",
|
"notion_integration_description": "Envía datos a tu base de datos de Notion",
|
||||||
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
|
"please_select_a_survey_error": "Por favor, selecciona una encuesta",
|
||||||
|
"reconnect_button": "Reconectar",
|
||||||
|
"reconnect_button_description": "Tu conexión de integración ha caducado. Por favor, reconecta para seguir sincronizando las respuestas. Tus enlaces y datos existentes se conservarán.",
|
||||||
|
"reconnect_button_tooltip": "Reconecta la integración para actualizar tu acceso. Tus enlaces y datos existentes se conservarán.",
|
||||||
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
|
"select_at_least_one_question_error": "Por favor, selecciona al menos una pregunta",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
|
"already_connected_another_survey": "Ya has conectado otra encuesta a este canal.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Este trimestre",
|
"this_quarter": "Este trimestre",
|
||||||
"this_year": "Este año",
|
"this_year": "Este año",
|
||||||
"time_to_complete": "Tiempo para completar",
|
"time_to_complete": "Tiempo para completar",
|
||||||
|
"ttc_survey_tooltip": "Tiempo promedio para completar la encuesta.",
|
||||||
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
|
"ttc_tooltip": "Tiempo medio para completar la pregunta.",
|
||||||
"unknown_question_type": "Tipo de pregunta desconocido",
|
"unknown_question_type": "Tipo de pregunta desconocido",
|
||||||
"use_personal_links": "Usar enlaces personales",
|
"use_personal_links": "Usar enlaces personales",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Envoyez des données à votre base de données Notion.",
|
"notion_integration_description": "Envoyez des données à votre base de données Notion.",
|
||||||
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
|
"please_select_a_survey_error": "Veuillez sélectionner une enquête.",
|
||||||
|
"reconnect_button": "Reconnecter",
|
||||||
|
"reconnect_button_description": "Ta connexion à l'intégration a expiré. Reconnecte-toi pour continuer à synchroniser les réponses. Tes liens et données existants seront conservés.",
|
||||||
|
"reconnect_button_tooltip": "Reconnecte l'intégration pour actualiser ton accès. Tes liens et données existants seront conservés.",
|
||||||
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
|
"select_at_least_one_question_error": "Veuillez sélectionner au moins une question.",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Vous avez déjà connecté une autre enquête à ce canal.",
|
"already_connected_another_survey": "Vous avez déjà connecté une autre enquête à ce canal.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Ce trimestre",
|
"this_quarter": "Ce trimestre",
|
||||||
"this_year": "Cette année",
|
"this_year": "Cette année",
|
||||||
"time_to_complete": "Temps à compléter",
|
"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.",
|
"ttc_tooltip": "Temps moyen pour compléter la question.",
|
||||||
"unknown_question_type": "Type de question inconnu",
|
"unknown_question_type": "Type de question inconnu",
|
||||||
"use_personal_links": "Utilisez des liens personnels",
|
"use_personal_links": "Utilisez des liens personnels",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
|
"notion_integration_description": "Adatok küldése a Notion-adatbázisba",
|
||||||
"please_select_a_survey_error": "Válasszon kérdőívet",
|
"please_select_a_survey_error": "Válasszon kérdőívet",
|
||||||
|
"reconnect_button": "Újracsatlakozás",
|
||||||
|
"reconnect_button_description": "Az integráció kapcsolata lejárt. Kérjük, csatlakozzon újra a válaszok szinkronizálásának folytatásához. A meglévő hivatkozások és adatok megmaradnak.",
|
||||||
|
"reconnect_button_tooltip": "Csatlakoztassa újra az integrációt a hozzáférés frissítéséhez. A meglévő hivatkozások és adatok megmaradnak.",
|
||||||
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
|
"select_at_least_one_question_error": "Válasszon legalább egy kérdést",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Már hozzákapcsolt egy másik kérdőívet ehhez a csatornához.",
|
"already_connected_another_survey": "Már hozzákapcsolt egy másik kérdőívet ehhez a csatornához.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Ez a negyedév",
|
"this_quarter": "Ez a negyedév",
|
||||||
"this_year": "Ez az év",
|
"this_year": "Ez az év",
|
||||||
"time_to_complete": "Kitöltéshez szükséges idő",
|
"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.",
|
"ttc_tooltip": "A kérdés megválaszolásának átlagos ideje.",
|
||||||
"unknown_question_type": "Ismeretlen kérdéstípus",
|
"unknown_question_type": "Ismeretlen kérdéstípus",
|
||||||
"use_personal_links": "Személyes hivatkozások használata",
|
"use_personal_links": "Személyes hivatkozások használata",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "回答を直接Notionに送信します",
|
"notion_integration_description": "回答を直接Notionに送信します",
|
||||||
"please_select_a_survey_error": "フォームを選択してください",
|
"please_select_a_survey_error": "フォームを選択してください",
|
||||||
|
"reconnect_button": "再接続",
|
||||||
|
"reconnect_button_description": "統合の接続が期限切れになりました。回答の同期を続けるには再接続してください。既存のリンクとデータは保持されます。",
|
||||||
|
"reconnect_button_tooltip": "統合を再接続してアクセスを更新します。既存のリンクとデータは保持されます。",
|
||||||
"select_at_least_one_question_error": "少なくとも1つの質問を選択してください",
|
"select_at_least_one_question_error": "少なくとも1つの質問を選択してください",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "このチャンネルには別のフォームがすでに接続されています。",
|
"already_connected_another_survey": "このチャンネルには別のフォームがすでに接続されています。",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "今四半期",
|
"this_quarter": "今四半期",
|
||||||
"this_year": "今年",
|
"this_year": "今年",
|
||||||
"time_to_complete": "完了までの時間",
|
"time_to_complete": "完了までの時間",
|
||||||
|
"ttc_survey_tooltip": "アンケートの平均完了時間。",
|
||||||
"ttc_tooltip": "フォームを完了するまでの平均時間。",
|
"ttc_tooltip": "フォームを完了するまでの平均時間。",
|
||||||
"unknown_question_type": "不明な質問の種類",
|
"unknown_question_type": "不明な質問の種類",
|
||||||
"use_personal_links": "個人リンクを使用",
|
"use_personal_links": "個人リンクを使用",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Verzend gegevens naar uw Notion-database",
|
"notion_integration_description": "Verzend gegevens naar uw Notion-database",
|
||||||
"please_select_a_survey_error": "Selecteer een enquête",
|
"please_select_a_survey_error": "Selecteer een enquête",
|
||||||
|
"reconnect_button": "Opnieuw verbinden",
|
||||||
|
"reconnect_button_description": "Je integratieverbinding is verlopen. Maak opnieuw verbinding om door te gaan met het synchroniseren van reacties. Je bestaande links en gegevens blijven behouden.",
|
||||||
|
"reconnect_button_tooltip": "Verbind de integratie opnieuw om je toegang te vernieuwen. Je bestaande links en gegevens blijven behouden.",
|
||||||
"select_at_least_one_question_error": "Selecteer minimaal één vraag",
|
"select_at_least_one_question_error": "Selecteer minimaal één vraag",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "U heeft al een andere enquête aan dit kanaal gekoppeld.",
|
"already_connected_another_survey": "U heeft al een andere enquête aan dit kanaal gekoppeld.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Dit kwartaal",
|
"this_quarter": "Dit kwartaal",
|
||||||
"this_year": "Dit jaar",
|
"this_year": "Dit jaar",
|
||||||
"time_to_complete": "Tijd om te voltooien",
|
"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.",
|
"ttc_tooltip": "Gemiddelde tijd om de vraag te beantwoorden.",
|
||||||
"unknown_question_type": "Onbekend vraagtype",
|
"unknown_question_type": "Onbekend vraagtype",
|
||||||
"use_personal_links": "Gebruik persoonlijke links",
|
"use_personal_links": "Gebruik persoonlijke links",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
|
"notion_integration_description": "Enviar dados para seu banco de dados do Notion",
|
||||||
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
|
"please_select_a_survey_error": "Por favor, escolha uma pesquisa",
|
||||||
|
"reconnect_button": "Reconectar",
|
||||||
|
"reconnect_button_description": "Sua conexão de integração expirou. Por favor, reconecte para continuar sincronizando respostas. Seus links e dados existentes serão preservados.",
|
||||||
|
"reconnect_button_tooltip": "Reconecte a integração para atualizar seu acesso. Seus links e dados existentes serão preservados.",
|
||||||
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Você já conectou outra pesquisa a este canal.",
|
"already_connected_another_survey": "Você já conectou outra pesquisa a este canal.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Este trimestre",
|
"this_quarter": "Este trimestre",
|
||||||
"this_year": "Este ano",
|
"this_year": "Este ano",
|
||||||
"time_to_complete": "Tempo para Concluir",
|
"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.",
|
"ttc_tooltip": "Tempo médio para completar a pergunta.",
|
||||||
"unknown_question_type": "Tipo de pergunta desconhecido",
|
"unknown_question_type": "Tipo de pergunta desconhecido",
|
||||||
"use_personal_links": "Use links pessoais",
|
"use_personal_links": "Use links pessoais",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
|
"notion_integration_description": "Enviar dados para a sua base de dados do Notion",
|
||||||
"please_select_a_survey_error": "Por favor, selecione um inquérito",
|
"please_select_a_survey_error": "Por favor, selecione um inquérito",
|
||||||
|
"reconnect_button": "Voltar a ligar",
|
||||||
|
"reconnect_button_description": "A ligação da tua integração expirou. Por favor, volta a ligar para continuar a sincronizar as respostas. As tuas ligações e dados existentes serão preservados.",
|
||||||
|
"reconnect_button_tooltip": "Volta a ligar a integração para atualizar o teu acesso. As tuas ligações e dados existentes serão preservados.",
|
||||||
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
"select_at_least_one_question_error": "Por favor, selecione pelo menos uma pergunta",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Já ligou outro inquérito a este canal.",
|
"already_connected_another_survey": "Já ligou outro inquérito a este canal.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Este trimestre",
|
"this_quarter": "Este trimestre",
|
||||||
"this_year": "Este ano",
|
"this_year": "Este ano",
|
||||||
"time_to_complete": "Tempo para Concluir",
|
"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.",
|
"ttc_tooltip": "Tempo médio para concluir a pergunta.",
|
||||||
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
"unknown_question_type": "Tipo de Pergunta Desconhecido",
|
||||||
"use_personal_links": "Utilize links pessoais",
|
"use_personal_links": "Utilize links pessoais",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Trimiteți datele în baza de date Notion",
|
"notion_integration_description": "Trimiteți datele în baza de date Notion",
|
||||||
"please_select_a_survey_error": "Vă rugăm să selectați un sondaj",
|
"please_select_a_survey_error": "Vă rugăm să selectați un sondaj",
|
||||||
|
"reconnect_button": "Reconectează",
|
||||||
|
"reconnect_button_description": "Conexiunea integrării tale a expirat. Te rugăm să te reconectezi pentru a continua sincronizarea răspunsurilor. Linkurile și datele tale existente vor fi păstrate.",
|
||||||
|
"reconnect_button_tooltip": "Reconectează integrarea pentru a reîmprospăta accesul. Linkurile și datele tale existente vor fi păstrate.",
|
||||||
"select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare",
|
"select_at_least_one_question_error": "Vă rugăm să selectați cel puțin o întrebare",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.",
|
"already_connected_another_survey": "Ați conectat deja un alt chestionar la acest canal.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Trimestrul acesta",
|
"this_quarter": "Trimestrul acesta",
|
||||||
"this_year": "Anul acesta",
|
"this_year": "Anul acesta",
|
||||||
"time_to_complete": "Timp de finalizare",
|
"time_to_complete": "Timp de finalizare",
|
||||||
|
"ttc_survey_tooltip": "Timpul mediu de finalizare a sondajului.",
|
||||||
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
|
"ttc_tooltip": "Timp mediu pentru a completa întrebarea.",
|
||||||
"unknown_question_type": "Tip de întrebare necunoscut",
|
"unknown_question_type": "Tip de întrebare necunoscut",
|
||||||
"use_personal_links": "Folosește linkuri personale",
|
"use_personal_links": "Folosește linkuri personale",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Отправляйте данные в вашу базу данных Notion",
|
"notion_integration_description": "Отправляйте данные в вашу базу данных Notion",
|
||||||
"please_select_a_survey_error": "Пожалуйста, выберите опрос",
|
"please_select_a_survey_error": "Пожалуйста, выберите опрос",
|
||||||
|
"reconnect_button": "Переподключить",
|
||||||
|
"reconnect_button_description": "Срок действия подключения интеграции истёк. Пожалуйста, переподключитесь, чтобы продолжить синхронизацию ответов. Ваши существующие ссылки и данные будут сохранены.",
|
||||||
|
"reconnect_button_tooltip": "Переподключите интеграцию, чтобы обновить доступ. Ваши существующие ссылки и данные будут сохранены.",
|
||||||
"select_at_least_one_question_error": "Пожалуйста, выберите хотя бы один вопрос",
|
"select_at_least_one_question_error": "Пожалуйста, выберите хотя бы один вопрос",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Вы уже подключили другой опрос к этому каналу.",
|
"already_connected_another_survey": "Вы уже подключили другой опрос к этому каналу.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "В этом квартале",
|
"this_quarter": "В этом квартале",
|
||||||
"this_year": "В этом году",
|
"this_year": "В этом году",
|
||||||
"time_to_complete": "Время на прохождение",
|
"time_to_complete": "Время на прохождение",
|
||||||
|
"ttc_survey_tooltip": "Среднее время прохождения опроса.",
|
||||||
"ttc_tooltip": "Среднее время на ответ на вопрос.",
|
"ttc_tooltip": "Среднее время на ответ на вопрос.",
|
||||||
"unknown_question_type": "Неизвестный тип вопроса",
|
"unknown_question_type": "Неизвестный тип вопроса",
|
||||||
"use_personal_links": "Использовать персональные ссылки",
|
"use_personal_links": "Использовать персональные ссылки",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "Skicka data till din Notion-databas",
|
"notion_integration_description": "Skicka data till din Notion-databas",
|
||||||
"please_select_a_survey_error": "Vänligen välj en enkät",
|
"please_select_a_survey_error": "Vänligen välj en enkät",
|
||||||
|
"reconnect_button": "Återanslut",
|
||||||
|
"reconnect_button_description": "Din integrationsanslutning har gått ut. Vänligen återanslut för att fortsätta synkronisera svar. Dina befintliga länkar och data kommer att bevaras.",
|
||||||
|
"reconnect_button_tooltip": "Återanslut integrationen för att uppdatera din åtkomst. Dina befintliga länkar och data kommer att bevaras.",
|
||||||
"select_at_least_one_question_error": "Vänligen välj minst en fråga",
|
"select_at_least_one_question_error": "Vänligen välj minst en fråga",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "Du har redan anslutit en annan enkät till denna kanal.",
|
"already_connected_another_survey": "Du har redan anslutit en annan enkät till denna kanal.",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "Detta kvartal",
|
"this_quarter": "Detta kvartal",
|
||||||
"this_year": "Detta år",
|
"this_year": "Detta år",
|
||||||
"time_to_complete": "Tid att slutföra",
|
"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.",
|
"ttc_tooltip": "Genomsnittlig tid för att slutföra frågan.",
|
||||||
"unknown_question_type": "Okänd frågetyp",
|
"unknown_question_type": "Okänd frågetyp",
|
||||||
"use_personal_links": "Använd personliga länkar",
|
"use_personal_links": "Använd personliga länkar",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "将 数据 发送到 您的 Notion 数据库",
|
"notion_integration_description": "将 数据 发送到 您的 Notion 数据库",
|
||||||
"please_select_a_survey_error": "请选择 一个 调查",
|
"please_select_a_survey_error": "请选择 一个 调查",
|
||||||
|
"reconnect_button": "重新连接",
|
||||||
|
"reconnect_button_description": "你的集成连接已过期。请重新连接以继续同步响应。你现有的链接和数据将被保留。",
|
||||||
|
"reconnect_button_tooltip": "重新连接集成以刷新你的访问权限。你现有的链接和数据将被保留。",
|
||||||
"select_at_least_one_question_error": "请选择至少 一个问题",
|
"select_at_least_one_question_error": "请选择至少 一个问题",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
|
"already_connected_another_survey": "您 已 经 将 另 一 个 调 查 连 接 到 此 频 道 。",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "本季度",
|
"this_quarter": "本季度",
|
||||||
"this_year": "今年",
|
"this_year": "今年",
|
||||||
"time_to_complete": "完成时间",
|
"time_to_complete": "完成时间",
|
||||||
|
"ttc_survey_tooltip": "完成调查的平均时间。",
|
||||||
"ttc_tooltip": "完成 本 问题 的 平均 时间",
|
"ttc_tooltip": "完成 本 问题 的 平均 时间",
|
||||||
"unknown_question_type": "未知 问题 类型",
|
"unknown_question_type": "未知 问题 类型",
|
||||||
"use_personal_links": "使用 个人 链接",
|
"use_personal_links": "使用 个人 链接",
|
||||||
|
|||||||
@@ -828,6 +828,9 @@
|
|||||||
},
|
},
|
||||||
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
|
"notion_integration_description": "將資料傳送至您的 Notion 資料庫",
|
||||||
"please_select_a_survey_error": "請選取問卷",
|
"please_select_a_survey_error": "請選取問卷",
|
||||||
|
"reconnect_button": "重新連接",
|
||||||
|
"reconnect_button_description": "您的整合連線已過期。請重新連接以繼續同步回應。您現有的連結和資料將會保留。",
|
||||||
|
"reconnect_button_tooltip": "重新連接整合以更新您的存取權限。您現有的連結和資料將會保留。",
|
||||||
"select_at_least_one_question_error": "請選取至少一個問題",
|
"select_at_least_one_question_error": "請選取至少一個問題",
|
||||||
"slack": {
|
"slack": {
|
||||||
"already_connected_another_survey": "您已將另一個問卷連線到此頻道。",
|
"already_connected_another_survey": "您已將另一個問卷連線到此頻道。",
|
||||||
@@ -2127,6 +2130,7 @@
|
|||||||
"this_quarter": "本季",
|
"this_quarter": "本季",
|
||||||
"this_year": "今年",
|
"this_year": "今年",
|
||||||
"time_to_complete": "完成時間",
|
"time_to_complete": "完成時間",
|
||||||
|
"ttc_survey_tooltip": "完成問卷調查的平均時間。",
|
||||||
"ttc_tooltip": "完成 問題 的 平均 時間。",
|
"ttc_tooltip": "完成 問題 的 平均 時間。",
|
||||||
"unknown_question_type": "未知的問題類型",
|
"unknown_question_type": "未知的問題類型",
|
||||||
"use_personal_links": "使用 個人 連結",
|
"use_personal_links": "使用 個人 連結",
|
||||||
|
|||||||
+6
-4
@@ -163,10 +163,12 @@ export const RenderResponse: React.FC<RenderResponseProps> = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else if (Array.isArray(responseData)) {
|
} else if (Array.isArray(responseData)) {
|
||||||
const itemsArray = responseData.map((choice) => {
|
const itemsArray = responseData
|
||||||
const choiceId = getChoiceIdByValue(choice, element);
|
.filter((choice) => choice !== "")
|
||||||
return { value: choice, id: choiceId };
|
.map((choice) => {
|
||||||
});
|
const choiceId = getChoiceIdByValue(choice, element);
|
||||||
|
return { value: choice, id: choiceId };
|
||||||
|
});
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{element.type === TSurveyElementTypeEnum.Ranking ? (
|
{element.type === TSurveyElementTypeEnum.Ranking ? (
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ const mockUser = {
|
|||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
password: "$2b$12$hashedPassword",
|
||||||
|
twoFactorSecret: "encrypted-2fa-secret",
|
||||||
|
backupCodes: "encrypted-backup-codes",
|
||||||
|
identityProviderAccountId: "provider-account-id",
|
||||||
role: "admin",
|
role: "admin",
|
||||||
memberships: [{ organizationId: "org456", role: "admin" }],
|
memberships: [{ organizationId: "org456", role: "admin" }],
|
||||||
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
|
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
|
||||||
@@ -60,6 +64,10 @@ describe("Users Lib", () => {
|
|||||||
updatedAt: expect.any(Date),
|
updatedAt: expect.any(Date),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
expect(result.data.data[0]).not.toHaveProperty("password");
|
||||||
|
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
|
||||||
|
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
|
||||||
|
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,6 +92,10 @@ describe("Users Lib", () => {
|
|||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
expect(result.data.id).toBe(mockUser.id);
|
expect(result.data.id).toBe(mockUser.id);
|
||||||
|
expect(result.data).not.toHaveProperty("password");
|
||||||
|
expect(result.data).not.toHaveProperty("twoFactorSecret");
|
||||||
|
expect(result.data).not.toHaveProperty("backupCodes");
|
||||||
|
expect(result.data).not.toHaveProperty("identityProviderAccountId");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,6 +163,10 @@ describe("Users Lib", () => {
|
|||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
expect(result.data.name).toBe("Updated User");
|
expect(result.data.name).toBe("Updated User");
|
||||||
|
expect(result.data).not.toHaveProperty("password");
|
||||||
|
expect(result.data).not.toHaveProperty("twoFactorSecret");
|
||||||
|
expect(result.data).not.toHaveProperty("backupCodes");
|
||||||
|
expect(result.data).not.toHaveProperty("identityProviderAccountId");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
await updateUser(user.id, {
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
...user.notificationSettings,
|
...user.notificationSettings,
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("SSO Providers", () => {
|
|||||||
expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
|
expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize");
|
||||||
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
|
expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token");
|
||||||
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
|
expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo");
|
||||||
expect(googleProvider.allowDangerousEmailAccountLinking).toBeUndefined();
|
expect((googleProvider as any).options?.allowDangerousEmailAccountLinking).toBe(true);
|
||||||
expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined();
|
expect(samlProvider.allowDangerousEmailAccountLinking).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export const getSSOProviders = () => [
|
|||||||
GoogleProvider({
|
GoogleProvider({
|
||||||
clientId: GOOGLE_CLIENT_ID || "",
|
clientId: GOOGLE_CLIENT_ID || "",
|
||||||
clientSecret: GOOGLE_CLIENT_SECRET || "",
|
clientSecret: GOOGLE_CLIENT_SECRET || "",
|
||||||
|
allowDangerousEmailAccountLinking: true,
|
||||||
}),
|
}),
|
||||||
AzureAD({
|
AzureAD({
|
||||||
clientId: AZUREAD_CLIENT_ID || "",
|
clientId: AZUREAD_CLIENT_ID || "",
|
||||||
@@ -80,6 +81,7 @@ export const getSSOProviders = () => [
|
|||||||
clientId: "dummy",
|
clientId: "dummy",
|
||||||
clientSecret: "dummy",
|
clientSecret: "dummy",
|
||||||
},
|
},
|
||||||
|
allowDangerousEmailAccountLinking: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ const LINKED_SSO_LOOKUP_SELECT = {
|
|||||||
identityProviderAccountId: true,
|
identityProviderAccountId: true,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked";
|
|
||||||
|
|
||||||
const syncSsoAccount = async (userId: string, account: Account, tx?: Prisma.TransactionClient) => {
|
const syncSsoAccount = async (userId: string, account: Account, tx?: Prisma.TransactionClient) => {
|
||||||
await upsertAccount(
|
await upsertAccount(
|
||||||
{
|
{
|
||||||
@@ -219,7 +217,7 @@ export const handleSsoCallback = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// There is no existing linked account for this identity provider / account id
|
// There is no existing linked account for this identity provider / account id
|
||||||
// check if a user account with this email already exists and fail closed if so
|
// check if a user account with this email already exists and auto-link it
|
||||||
contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email");
|
contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email");
|
||||||
|
|
||||||
const existingUserWithEmail = await getUserByEmail(user.email);
|
const existingUserWithEmail = await getUserByEmail(user.email);
|
||||||
@@ -230,9 +228,10 @@ export const handleSsoCallback = async ({
|
|||||||
existingUserId: existingUserWithEmail.id,
|
existingUserId: existingUserWithEmail.id,
|
||||||
existingIdentityProvider: existingUserWithEmail.identityProvider,
|
existingIdentityProvider: existingUserWithEmail.identityProvider,
|
||||||
},
|
},
|
||||||
"SSO callback blocked: existing user found by email without linked provider account"
|
"SSO callback successful: existing user found by email"
|
||||||
);
|
);
|
||||||
throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR);
|
await syncSsoAccount(existingUserWithEmail.id, account);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
contextLogger.debug(
|
contextLogger.debug(
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ describe("handleSsoCallback", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should reject verified email users whose SSO provider is not already linked", async () => {
|
test("should auto-link verified email users whose SSO provider is not already linked", async () => {
|
||||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||||
id: "existing-user-id",
|
id: "existing-user-id",
|
||||||
@@ -349,22 +349,26 @@ describe("handleSsoCallback", () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
const result = await handleSsoCallback({
|
||||||
handleSsoCallback({
|
user: mockUser,
|
||||||
user: mockUser,
|
account: mockAccount,
|
||||||
account: mockAccount,
|
callbackUrl: "http://localhost:3000",
|
||||||
callbackUrl: "http://localhost:3000",
|
});
|
||||||
})
|
|
||||||
).rejects.toThrow("OAuthAccountNotLinked");
|
expect(result).toBe(true);
|
||||||
expect(upsertAccount).not.toHaveBeenCalled();
|
expect(upsertAccount).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: "existing-user-id",
|
||||||
|
provider: mockAccount.provider,
|
||||||
|
providerAccountId: mockAccount.providerAccountId,
|
||||||
|
}),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
expect(updateUser).not.toHaveBeenCalled();
|
expect(updateUser).not.toHaveBeenCalled();
|
||||||
expect(createUser).not.toHaveBeenCalled();
|
expect(createUser).not.toHaveBeenCalled();
|
||||||
expect(createMembership).not.toHaveBeenCalled();
|
|
||||||
expect(createBrevoCustomer).not.toHaveBeenCalled();
|
|
||||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should reject unverified email users whose SSO provider is not already linked", async () => {
|
test("should auto-link unverified email users whose SSO provider is not already linked", async () => {
|
||||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||||
@@ -376,22 +380,26 @@ describe("handleSsoCallback", () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
const result = await handleSsoCallback({
|
||||||
handleSsoCallback({
|
user: mockUser,
|
||||||
user: mockUser,
|
account: mockAccount,
|
||||||
account: mockAccount,
|
callbackUrl: "http://localhost:3000",
|
||||||
callbackUrl: "http://localhost:3000",
|
});
|
||||||
})
|
|
||||||
).rejects.toThrow("OAuthAccountNotLinked");
|
expect(result).toBe(true);
|
||||||
expect(upsertAccount).not.toHaveBeenCalled();
|
expect(upsertAccount).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: "existing-user-id",
|
||||||
|
provider: mockAccount.provider,
|
||||||
|
providerAccountId: mockAccount.providerAccountId,
|
||||||
|
}),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
expect(updateUser).not.toHaveBeenCalled();
|
expect(updateUser).not.toHaveBeenCalled();
|
||||||
expect(createUser).not.toHaveBeenCalled();
|
expect(createUser).not.toHaveBeenCalled();
|
||||||
expect(createMembership).not.toHaveBeenCalled();
|
|
||||||
expect(createBrevoCustomer).not.toHaveBeenCalled();
|
|
||||||
expect(capturePostHogEvent).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should reject existing users from a different SSO provider when no link exists", async () => {
|
test("should auto-link existing users from a different SSO provider when no link exists", async () => {
|
||||||
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||||
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||||
vi.mocked(getUserByEmail).mockResolvedValue({
|
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||||
@@ -403,14 +411,53 @@ describe("handleSsoCallback", () => {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
const result = await handleSsoCallback({
|
||||||
handleSsoCallback({
|
user: mockUser,
|
||||||
user: mockUser,
|
account: mockAccount,
|
||||||
account: mockAccount,
|
callbackUrl: "http://localhost:3000",
|
||||||
callbackUrl: "http://localhost:3000",
|
});
|
||||||
})
|
|
||||||
).rejects.toThrow("OAuthAccountNotLinked");
|
expect(result).toBe(true);
|
||||||
expect(upsertAccount).not.toHaveBeenCalled();
|
expect(upsertAccount).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: "existing-user-id",
|
||||||
|
provider: mockAccount.provider,
|
||||||
|
providerAccountId: mockAccount.providerAccountId,
|
||||||
|
}),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(updateUser).not.toHaveBeenCalled();
|
||||||
|
expect(createUser).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should auto-link same-email users even when the stored legacy provider account id is stale", async () => {
|
||||||
|
vi.mocked(prisma.account.findUnique).mockResolvedValue(null);
|
||||||
|
vi.mocked(prisma.user.findFirst).mockResolvedValue(null);
|
||||||
|
vi.mocked(getUserByEmail).mockResolvedValue({
|
||||||
|
id: "existing-user-id",
|
||||||
|
email: mockUser.email,
|
||||||
|
emailVerified: new Date(),
|
||||||
|
identityProvider: "google",
|
||||||
|
identityProviderAccountId: "old-provider-id",
|
||||||
|
locale: mockUser.locale,
|
||||||
|
isActive: true,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const result = await handleSsoCallback({
|
||||||
|
user: mockUser,
|
||||||
|
account: mockAccount,
|
||||||
|
callbackUrl: "http://localhost:3000",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(upsertAccount).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
userId: "existing-user-id",
|
||||||
|
provider: mockAccount.provider,
|
||||||
|
providerAccountId: mockAccount.providerAccountId,
|
||||||
|
}),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
expect(updateUser).not.toHaveBeenCalled();
|
expect(updateUser).not.toHaveBeenCalled();
|
||||||
expect(createUser).not.toHaveBeenCalled();
|
expect(createUser).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export const AddWebhookModal = ({
|
|||||||
url: testEndpointInput,
|
url: testEndpointInput,
|
||||||
secret: webhookSecret,
|
secret: webhookSecret,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!testEndpointActionResult?.data) {
|
if (!testEndpointActionResult?.data) {
|
||||||
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
|
const errorMessage = getFormattedErrorMessage(testEndpointActionResult);
|
||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ vi.mock("@formbricks/database", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const constantsMock = vi.hoisted(() => ({ dangerouslyAllow: false }));
|
||||||
|
|
||||||
|
vi.mock("@/lib/constants", () => ({
|
||||||
|
get DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS() {
|
||||||
|
return constantsMock.dangerouslyAllow;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/crypto", () => ({
|
vi.mock("@/lib/crypto", () => ({
|
||||||
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
|
generateStandardWebhookSignature: vi.fn(() => "signed-payload"),
|
||||||
generateWebhookSecret: vi.fn(() => "generated-secret"),
|
generateWebhookSecret: vi.fn(() => "generated-secret"),
|
||||||
@@ -41,6 +49,7 @@ vi.mock("uuid", () => ({
|
|||||||
describe("testEndpoint", () => {
|
describe("testEndpoint", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
|
constantsMock.dangerouslyAllow = false;
|
||||||
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
|
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
|
||||||
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
|
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
|
||||||
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
|
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
|
||||||
@@ -76,6 +85,36 @@ describe("testEndpoint", () => {
|
|||||||
expect(getTranslate).toHaveBeenCalled();
|
expect(getTranslate).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.each([301, 302, 303, 307, 308])(
|
||||||
|
"rejects %s redirects to prevent SSRF via redirect",
|
||||||
|
async (statusCode) => {
|
||||||
|
const fetchMock = vi.fn(async () => ({ status: statusCode }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(testEndpoint("https://example.com/webhook")).rejects.toThrow(
|
||||||
|
"Webhook endpoint returned a redirect, which is not allowed"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://example.com/webhook",
|
||||||
|
expect.objectContaining({ redirect: "manual" })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test("follows redirects when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
|
||||||
|
constantsMock.dangerouslyAllow = true;
|
||||||
|
const fetchMock = vi.fn(async () => ({ status: 200 }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
await expect(testEndpoint("https://example.com/webhook")).resolves.toBe(true);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"https://example.com/webhook",
|
||||||
|
expect.objectContaining({ redirect: "follow" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("allows non-blocked non-2xx statuses", async () => {
|
test("allows non-blocked non-2xx statuses", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
ResourceNotFoundError,
|
ResourceNotFoundError,
|
||||||
UnknownError,
|
UnknownError,
|
||||||
} from "@formbricks/types/errors";
|
} from "@formbricks/types/errors";
|
||||||
|
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
|
||||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||||
import { validateInputs } from "@/lib/utils/validate";
|
import { validateInputs } from "@/lib/utils/validate";
|
||||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||||
@@ -191,13 +192,29 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `redirect: "manual"` prevents SSRF via redirect — validateWebhookUrl only checks the
|
||||||
|
// initial URL, so following 30x to a private/internal host (e.g. cloud metadata) would bypass it.
|
||||||
|
// Gated on the same env var as validateWebhookUrl: self-hosters who opted into trusting internal
|
||||||
|
// URLs also get the pre-patch redirect-follow behavior for consistency.
|
||||||
|
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body,
|
body,
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
redirect: redirectMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
const statusCode = response.status;
|
const statusCode = response.status;
|
||||||
|
|
||||||
|
// With `redirect: "manual"`, Node's undici returns the actual 30x response (not the spec's
|
||||||
|
// opaqueredirect filter). Treat any 30x as a redirect rejection so users get a clear error
|
||||||
|
// instead of a misleading success. With `redirect: "follow"`, fetch returns the final 2xx/4xx/5xx
|
||||||
|
// and this branch is unreachable.
|
||||||
|
if (statusCode >= 300 && statusCode < 400) {
|
||||||
|
throw new InvalidInputError("Webhook endpoint returned a redirect, which is not allowed");
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = await getWebhookTestErrorMessage(statusCode);
|
const errorMessage = await getWebhookTestErrorMessage(statusCode);
|
||||||
|
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import { logger } from "@formbricks/logger";
|
||||||
|
import { test } from "../../lib/fixtures";
|
||||||
|
|
||||||
|
test.describe("API Tests for Management Me", () => {
|
||||||
|
test("Authenticated v1 me endpoint never exposes secret auth fields", async ({ page, users }) => {
|
||||||
|
const name = `Security Me User ${Date.now()}`;
|
||||||
|
const email = `security-me-${Date.now()}@example.com`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await users.create({ name, email });
|
||||||
|
await user.login();
|
||||||
|
|
||||||
|
const response = await page.context().request.get("/api/v1/management/me");
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
|
||||||
|
const responseBody = await response.json();
|
||||||
|
|
||||||
|
expect(responseBody).toMatchObject({
|
||||||
|
id: expect.any(String),
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
twoFactorEnabled: expect.any(Boolean),
|
||||||
|
identityProvider: expect.any(String),
|
||||||
|
notificationSettings: expect.any(Object),
|
||||||
|
locale: expect.any(String),
|
||||||
|
isActive: expect.any(Boolean),
|
||||||
|
});
|
||||||
|
expect(responseBody).not.toHaveProperty("password");
|
||||||
|
expect(responseBody).not.toHaveProperty("twoFactorSecret");
|
||||||
|
expect(responseBody).not.toHaveProperty("backupCodes");
|
||||||
|
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error, "Error during management me API security test");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Background Job Processing"
|
|
||||||
description: "How BullMQ works in Formbricks today, including the migrated response pipeline workload."
|
|
||||||
icon: "code"
|
|
||||||
---
|
|
||||||
|
|
||||||
This page documents the current BullMQ-based background job system in Formbricks and the first real workload that now runs on it: the response pipeline.
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
Formbricks now uses BullMQ as an in-process background job system inside the Next.js web application.
|
|
||||||
|
|
||||||
The current implementation includes:
|
|
||||||
|
|
||||||
- a shared `@formbricks/jobs` package that owns queue creation, schemas, scheduling, and worker runtime concerns
|
|
||||||
- a Next.js startup hook that starts one BullMQ worker runtime per Node.js process without blocking app boot
|
|
||||||
- app-level enqueue helpers for request handlers
|
|
||||||
- an app-owned BullMQ response pipeline processor that replaces the legacy internal HTTP pipeline route
|
|
||||||
|
|
||||||
The first migrated workload is:
|
|
||||||
|
|
||||||
- `response-pipeline.process`
|
|
||||||
|
|
||||||
This means response-related side effects no longer depend on an internal `fetch()` back into the same app process.
|
|
||||||
|
|
||||||
## Why This Exists
|
|
||||||
|
|
||||||
The original response pipeline lived behind an internal Next.js route:
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/web/app/api/(internal)/pipeline
|
|
||||||
```
|
|
||||||
|
|
||||||
That model had a few problems:
|
|
||||||
|
|
||||||
- it was tightly coupled to the request lifecycle
|
|
||||||
- it relied on an internal HTTP hop instead of a typed background-job boundary
|
|
||||||
- it was harder to observe, retry, and scale safely
|
|
||||||
|
|
||||||
BullMQ addresses that by moving post-response work behind a queue while keeping the first version operationally simple for self-hosted users.
|
|
||||||
|
|
||||||
## High-Level Architecture
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
graph TD
|
|
||||||
A["API route or server code"] --> B["enqueueResponsePipelineEvents()"]
|
|
||||||
B --> C["getResponseSnapshotForPipeline()"]
|
|
||||||
B --> D["BackgroundJobProducer.enqueueResponsePipeline()"]
|
|
||||||
D --> E["BullMQ queue: background-jobs"]
|
|
||||||
F["instrumentation.ts"] --> G["registerJobsWorker()"]
|
|
||||||
G --> H["startJobsRuntime()"]
|
|
||||||
H --> I["BullMQ workers"]
|
|
||||||
I --> J["response-pipeline.process override"]
|
|
||||||
J --> K["processResponsePipelineJob()"]
|
|
||||||
E --> I
|
|
||||||
E --> L["Redis / Valkey"]
|
|
||||||
I --> L
|
|
||||||
```
|
|
||||||
|
|
||||||
## Responsibilities By Layer
|
|
||||||
|
|
||||||
### App Layer
|
|
||||||
|
|
||||||
- `apps/web/app/lib/pipelines.ts`
|
|
||||||
Owns enqueueing for response pipeline events. It gates queueing, hydrates the response snapshot once, logs failures, and never throws back into request handlers.
|
|
||||||
- `apps/web/modules/response-pipeline/lib/process-response-pipeline-job.ts`
|
|
||||||
Owns app-specific execution of response-pipeline jobs.
|
|
||||||
- `apps/web/modules/response-pipeline/lib/handle-integrations.ts`
|
|
||||||
Owns Slack, Notion, Airtable, and Google Sheets integration fan-out for the pipeline.
|
|
||||||
- `apps/web/modules/response-pipeline/lib/telemetry.ts`
|
|
||||||
Owns telemetry dispatch logic used by the response-created path.
|
|
||||||
- `apps/web/instrumentation-jobs.ts`
|
|
||||||
Registers the app-owned response-pipeline handler override with the shared BullMQ runtime and schedules retry after transient startup failures.
|
|
||||||
- `apps/web/lib/jobs/config.ts`
|
|
||||||
Turns environment configuration into queueing and worker-bootstrap decisions. Queue producers depend on `REDIS_URL`; worker startup additionally depends on `BULLMQ_WORKER_ENABLED`.
|
|
||||||
|
|
||||||
### Shared Jobs Layer
|
|
||||||
|
|
||||||
- `packages/jobs/src/types.ts`
|
|
||||||
Defines typed payload schemas such as `TResponsePipelineJobData`.
|
|
||||||
- `packages/jobs/src/definitions.ts`
|
|
||||||
Defines stable job names and payload validation.
|
|
||||||
- `packages/jobs/src/queue.ts`
|
|
||||||
Owns producer-side enqueueing and scheduling.
|
|
||||||
- `packages/jobs/src/runtime.ts`
|
|
||||||
Starts workers, connects Redis, and handles graceful shutdown.
|
|
||||||
- `packages/jobs/src/processors/registry.ts`
|
|
||||||
Validates payloads and dispatches named jobs, applying app-provided handler overrides when registered.
|
|
||||||
|
|
||||||
## Response Pipeline Flow
|
|
||||||
|
|
||||||
The response pipeline now runs fully in the background worker.
|
|
||||||
|
|
||||||
### Enqueueing
|
|
||||||
|
|
||||||
When a response is created or updated, the request path calls:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
enqueueResponsePipelineEvents({
|
|
||||||
environmentId,
|
|
||||||
surveyId,
|
|
||||||
responseId,
|
|
||||||
events,
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
That helper:
|
|
||||||
|
|
||||||
1. deduplicates requested events
|
|
||||||
2. checks whether BullMQ queueing is enabled
|
|
||||||
3. uses the just-written response snapshot when the caller already has it
|
|
||||||
4. otherwise loads the latest response snapshot once via `getResponseSnapshotForPipeline(responseId)` using an uncached read
|
|
||||||
5. enqueues one BullMQ job per event with the shared snapshot payload
|
|
||||||
6. waits for the enqueue attempt to complete, then logs enqueue failures without failing the original request
|
|
||||||
|
|
||||||
### Execution
|
|
||||||
|
|
||||||
At worker startup, `apps/web/instrumentation-jobs.ts` registers an app-owned override for:
|
|
||||||
|
|
||||||
- `response-pipeline.process`
|
|
||||||
|
|
||||||
That override delegates to `processResponsePipelineJob(...)`, which performs:
|
|
||||||
|
|
||||||
- webhook delivery for all pipeline events
|
|
||||||
- integrations for `responseFinished`
|
|
||||||
- response-finished notification emails
|
|
||||||
- follow-up delivery
|
|
||||||
- survey auto-complete updates and audit logging
|
|
||||||
- response-created billing metering
|
|
||||||
- response-created telemetry dispatch
|
|
||||||
|
|
||||||
Current retry semantics are intentionally asymmetric:
|
|
||||||
|
|
||||||
- webhook delivery failures fail early BullMQ attempts so retries can happen at the job level
|
|
||||||
- if webhook delivery is still failing on the final BullMQ attempt, the worker logs that retries are exhausted and continues with the remaining event-specific side effects
|
|
||||||
- integration, email, telemetry, metering, follow-up, and survey auto-complete failures are logged inside the processor and do not fail the whole job
|
|
||||||
|
|
||||||
## Acceptance Criteria Review
|
|
||||||
|
|
||||||
### Pipeline Execution
|
|
||||||
|
|
||||||
Satisfied.
|
|
||||||
|
|
||||||
- New response create/update flows enqueue BullMQ jobs instead of calling an internal HTTP route.
|
|
||||||
- The job payload contains `environmentId`, `surveyId`, `event`, and an authoritative response snapshot.
|
|
||||||
- The response pipeline executes inside the BullMQ worker runtime.
|
|
||||||
|
|
||||||
### Feature Parity
|
|
||||||
|
|
||||||
Mostly satisfied for the legacy response pipeline behavior that existed in the old route.
|
|
||||||
|
|
||||||
The migrated BullMQ processor preserves:
|
|
||||||
|
|
||||||
- webhook delivery
|
|
||||||
- integrations
|
|
||||||
- response-finished emails
|
|
||||||
- follow-up execution
|
|
||||||
- survey auto-complete and audit logging
|
|
||||||
- response-created billing metering
|
|
||||||
- response-created telemetry
|
|
||||||
|
|
||||||
One important behavior change still exists today:
|
|
||||||
|
|
||||||
- webhook delivery failures delay the remaining side effects until the final BullMQ attempt
|
|
||||||
|
|
||||||
That is closer to the legacy route, because the pipeline eventually continues even if webhook delivery never succeeds. It is still not exact feature parity, though, because the legacy route continued immediately while the BullMQ worker waits until retries are exhausted before it degrades webhook failure into a logged condition.
|
|
||||||
|
|
||||||
### Architecture
|
|
||||||
|
|
||||||
Satisfied.
|
|
||||||
|
|
||||||
- Enqueueing lives in the app layer through `apps/web/app/lib/pipelines.ts`.
|
|
||||||
- Execution lives in the worker path under `apps/web/modules/response-pipeline/lib`.
|
|
||||||
- `@formbricks/jobs` stays responsible for queue/runtime concerns and typed job contracts.
|
|
||||||
|
|
||||||
### Cleanup
|
|
||||||
|
|
||||||
Satisfied.
|
|
||||||
|
|
||||||
The legacy internal route has been removed:
|
|
||||||
|
|
||||||
```text
|
|
||||||
apps/web/app/api/(internal)/pipeline/route.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
The runtime path no longer depends on the old internal-route folder structure, and the remaining pipeline-only test mock under that deleted folder has been removed as part of the migration cleanup.
|
|
||||||
|
|
||||||
### Reliability
|
|
||||||
|
|
||||||
Satisfied at the current ticket scope.
|
|
||||||
|
|
||||||
BullMQ jobs use shared default retry behavior:
|
|
||||||
|
|
||||||
- `attempts: 3`
|
|
||||||
- exponential backoff starting at `1000ms`
|
|
||||||
|
|
||||||
Failures are logged with structured metadata such as:
|
|
||||||
|
|
||||||
- `jobId`
|
|
||||||
- `attempt`
|
|
||||||
- `jobName`
|
|
||||||
- `queueName`
|
|
||||||
- `environmentId`
|
|
||||||
- `surveyId`
|
|
||||||
- `responseId`
|
|
||||||
|
|
||||||
Request handlers remain non-blocking:
|
|
||||||
|
|
||||||
- if Redis is unavailable
|
|
||||||
- if queueing is disabled
|
|
||||||
- if snapshot hydration fails
|
|
||||||
- if enqueueing fails
|
|
||||||
|
|
||||||
the request still completes, and the failure is logged.
|
|
||||||
|
|
||||||
Worker startup is also non-blocking:
|
|
||||||
|
|
||||||
- Next.js boot does not await BullMQ readiness
|
|
||||||
- startup failures are logged
|
|
||||||
- the web app schedules a retry instead of requiring an immediate process restart
|
|
||||||
|
|
||||||
### Worker Integration
|
|
||||||
|
|
||||||
Satisfied.
|
|
||||||
|
|
||||||
The response pipeline is processed by the same BullMQ worker runtime started from Next.js instrumentation. No standalone worker service was introduced as part of this migration.
|
|
||||||
|
|
||||||
### Developer Experience
|
|
||||||
|
|
||||||
Satisfied.
|
|
||||||
|
|
||||||
The public app-level API for request handlers is intentionally small:
|
|
||||||
|
|
||||||
- `enqueueResponsePipelineEvents(...)`
|
|
||||||
|
|
||||||
This keeps queue names, Redis concerns, and BullMQ details out of response routes.
|
|
||||||
|
|
||||||
## Comparison With The Legacy Route
|
|
||||||
|
|
||||||
### Previous Implementation
|
|
||||||
|
|
||||||
The legacy internal route accepted a full response payload directly and then executed the entire pipeline synchronously inside the route handler.
|
|
||||||
|
|
||||||
Key characteristics of that model:
|
|
||||||
|
|
||||||
- request handlers performed an internal authenticated `fetch()` back into the same app
|
|
||||||
- the route received the response payload directly instead of hydrating it from a queue-side snapshot
|
|
||||||
- webhook failures were logged and did not block the rest of the pipeline
|
|
||||||
- response-finished integrations, emails, follow-ups, and survey auto-complete ran in the same route execution
|
|
||||||
- response-created metering was fire-and-forget while telemetry was awaited
|
|
||||||
|
|
||||||
### Current BullMQ Implementation
|
|
||||||
|
|
||||||
The current branch enqueues a typed snapshot-based BullMQ job and executes the pipeline inside the in-process worker registered from Next.js instrumentation.
|
|
||||||
|
|
||||||
Key characteristics of the current model:
|
|
||||||
|
|
||||||
- request handlers enqueue directly through `enqueueResponsePipelineEvents(...)`
|
|
||||||
- handlers now pass the just-written `TResponse` snapshot when they already have it
|
|
||||||
- callers that do not already have a response snapshot use an uncached pipeline-specific lookup
|
|
||||||
- worker startup is non-blocking and retries after transient failures
|
|
||||||
- webhook failures fail early attempts so BullMQ can retry them
|
|
||||||
- on the final attempt, webhook failures are logged and the remaining side effects continue
|
|
||||||
- response-created metering is awaited before the BullMQ job completes
|
|
||||||
|
|
||||||
### Net Result
|
|
||||||
|
|
||||||
Compared to the legacy route, the current branch is:
|
|
||||||
|
|
||||||
- architecturally stronger
|
|
||||||
- safer to scale and operate
|
|
||||||
- easier to observe through structured job logging
|
|
||||||
- closer to legacy feature parity than the earlier BullMQ iterations on this branch
|
|
||||||
|
|
||||||
The main remaining semantic difference is timing:
|
|
||||||
|
|
||||||
- the legacy route continued past webhook failures immediately
|
|
||||||
- the BullMQ worker now continues only after webhook retries are exhausted
|
|
||||||
|
|
||||||
That is an intentional trade-off in the current branch, not an accident.
|
|
||||||
|
|
||||||
## Current Queue Model
|
|
||||||
|
|
||||||
The queue remains intentionally small:
|
|
||||||
|
|
||||||
- queue name: `background-jobs`
|
|
||||||
- prefix: `formbricks:jobs`
|
|
||||||
- job names:
|
|
||||||
- `system.test-log`
|
|
||||||
- `response-pipeline.process`
|
|
||||||
|
|
||||||
The response pipeline is the first production workload on this queue.
|
|
||||||
|
|
||||||
## Local Development
|
|
||||||
|
|
||||||
Local development works end to end as long as Redis is available and the worker is enabled.
|
|
||||||
|
|
||||||
Required inputs:
|
|
||||||
|
|
||||||
- `REDIS_URL`
|
|
||||||
- optionally `BULLMQ_WORKER_ENABLED`
|
|
||||||
- optionally `BULLMQ_WORKER_COUNT`
|
|
||||||
- optionally `BULLMQ_WORKER_CONCURRENCY`
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- if `REDIS_URL` is missing, queueing is skipped
|
|
||||||
- if `BULLMQ_WORKER_ENABLED=0`, the worker is not started, but request-side enqueueing can still stay enabled in deployments that point at a separate BullMQ worker
|
|
||||||
- outside tests, the worker is enabled by default
|
|
||||||
|
|
||||||
This makes it possible to develop request flows without hard-failing when Redis is absent, while still supporting full local end-to-end verification when Redis is running.
|
|
||||||
|
|
||||||
## Operational Notes
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
|
|
||||||
The current implementation logs:
|
|
||||||
|
|
||||||
- worker startup failures
|
|
||||||
- Redis connection failures
|
|
||||||
- enqueue failures
|
|
||||||
- job failures
|
|
||||||
- webhook delivery failures
|
|
||||||
- integration failures
|
|
||||||
- email delivery failures
|
|
||||||
- follow-up failures
|
|
||||||
- survey auto-complete update failures
|
|
||||||
- metering failures
|
|
||||||
- telemetry failures
|
|
||||||
|
|
||||||
### Shutdown
|
|
||||||
|
|
||||||
The worker runtime registers `SIGTERM` and `SIGINT` handlers, closes workers and queue handles, and then closes Redis connections. This keeps shutdown behavior predictable inside the web process.
|
|
||||||
|
|
||||||
## Current Limitations
|
|
||||||
|
|
||||||
The migration satisfies the ticket, but a few larger architectural limits remain by design.
|
|
||||||
|
|
||||||
### Dual-Write Boundary
|
|
||||||
|
|
||||||
Response writes happen in Postgres and background jobs are enqueued in Redis. Those are separate systems, so this remains a dual-write boundary.
|
|
||||||
|
|
||||||
This means Formbricks currently has:
|
|
||||||
|
|
||||||
- non-blocking enqueue semantics
|
|
||||||
- at-least-once background execution
|
|
||||||
- no transactional guarantee that the product write and Redis enqueue succeed together
|
|
||||||
|
|
||||||
That trade-off was accepted for this BullMQ phase.
|
|
||||||
|
|
||||||
### In-Process Workers
|
|
||||||
|
|
||||||
Workers run inside the Next.js app process.
|
|
||||||
|
|
||||||
That keeps self-hosting simple, but it also means:
|
|
||||||
|
|
||||||
- job capacity still shares resources with the web process
|
|
||||||
- heavy background work is still Node.js-local
|
|
||||||
- scaling job throughput also scales the app runtime
|
|
||||||
|
|
||||||
### Webhook-Gated Retries
|
|
||||||
|
|
||||||
Webhook delivery still happens before the rest of the `responseFinished` side effects.
|
|
||||||
|
|
||||||
That gives Formbricks job-level retries for webhook delivery, but it also means:
|
|
||||||
|
|
||||||
- `responseFinished` side effects do not run on the early retry attempts
|
|
||||||
- the remaining side effects only continue after webhook retries are exhausted
|
|
||||||
- this is closer to legacy behavior than failing forever, but it is still not immediate parity
|
|
||||||
|
|
||||||
This is the current behavior of the branch and should be evaluated explicitly if we want stricter feature parity with the legacy route.
|
|
||||||
|
|
||||||
### Logs-First Observability
|
|
||||||
|
|
||||||
The current system has strong structured logging, but it does not yet provide:
|
|
||||||
|
|
||||||
- queue dashboards
|
|
||||||
- retry tooling
|
|
||||||
- latency metrics
|
|
||||||
- product-native workflow inspection
|
|
||||||
|
|
||||||
Those are future improvements, not blockers for the current migration.
|
|
||||||
|
|
||||||
## Recommended Next Steps
|
|
||||||
|
|
||||||
Now that the response pipeline is on BullMQ, the most useful next steps are:
|
|
||||||
|
|
||||||
1. migrate additional low-risk async workloads behind the same producer/runtime boundary
|
|
||||||
2. add queue metrics and worker health visibility beyond logs
|
|
||||||
3. define explicit idempotency rules for side-effect-heavy jobs
|
|
||||||
4. decide which future workloads should remain Node-local and which should eventually move to a different runtime
|
|
||||||
|
|
||||||
## Practical Conclusion
|
|
||||||
|
|
||||||
Formbricks now has:
|
|
||||||
|
|
||||||
- a production-capable BullMQ foundation
|
|
||||||
- a real migrated workload
|
|
||||||
- a clean separation between request-time enqueueing and background execution
|
|
||||||
|
|
||||||
The response pipeline migration should be considered complete for the current ticket scope.
|
|
||||||
@@ -280,6 +280,7 @@ function DropdownVariant({
|
|||||||
placeholder={otherOptionPlaceholder}
|
placeholder={otherOptionPlaceholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-required={required}
|
aria-required={required}
|
||||||
|
aria-invalid={Boolean(errorMessage)}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
className="mt-2 w-full"
|
className="mt-2 w-full"
|
||||||
/>
|
/>
|
||||||
@@ -401,6 +402,7 @@ function ListVariant({
|
|||||||
placeholder={otherOptionPlaceholder}
|
placeholder={otherOptionPlaceholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-required={required}
|
aria-required={required}
|
||||||
|
aria-invalid={Boolean(errorMessage)}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
className="mt-2 w-full"
|
className="mt-2 w-full"
|
||||||
ref={otherInputRef}
|
ref={otherInputRef}
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ function SingleSelect({
|
|||||||
onChange={handleOtherInputChange}
|
onChange={handleOtherInputChange}
|
||||||
placeholder={otherOptionPlaceholder}
|
placeholder={otherOptionPlaceholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-invalid={Boolean(errorMessage)}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
className="mt-2 w-full"
|
className="mt-2 w-full"
|
||||||
/>
|
/>
|
||||||
@@ -334,6 +335,7 @@ function SingleSelect({
|
|||||||
placeholder={otherOptionPlaceholder}
|
placeholder={otherOptionPlaceholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-required={required}
|
aria-required={required}
|
||||||
|
aria-invalid={Boolean(errorMessage)}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
className="mt-2 w-full"
|
className="mt-2 w-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -4,17 +4,16 @@ import * as React from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
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: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
destructive:
|
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",
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20",
|
||||||
outline:
|
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
|
||||||
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
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",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
custom: "button-custom",
|
custom: "button-custom",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ function CalendarDayButton({
|
|||||||
data-range-end={modifiers.range_end}
|
data-range-end={modifiers.range_end}
|
||||||
data-range-middle={modifiers.range_middle}
|
data-range-middle={modifiers.range_middle}
|
||||||
className={cn(
|
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,
|
defaultClassNames.day,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function Checkbox({
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
data-slot="checkbox"
|
data-slot="checkbox"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}>
|
{...props}>
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
|
|||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
className,
|
className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
|
ref,
|
||||||
...props
|
...props
|
||||||
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Content>>) {
|
}: Readonly<React.ComponentProps<typeof DropdownMenuPrimitive.Content>>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<div id="fbjs">
|
<div id="fbjs">
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -58,7 +60,7 @@ function DropdownMenuItem({
|
|||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ export function useDropdownSearch<T extends { id: string; label: string }>({
|
|||||||
|
|
||||||
const focusSearchAndLockSide = (): void => {
|
const focusSearchAndLockSide = (): void => {
|
||||||
searchInputRef.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
const side = contentRef.current?.dataset.side;
|
const dataset = contentRef.current?.dataset;
|
||||||
|
if (!dataset) return;
|
||||||
|
const side = dataset.side;
|
||||||
if (side === "top" || side === "bottom") setLockedSide(side);
|
if (side === "top" || side === "bottom") setLockedSide(side);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input(
|
|||||||
// Focus ring
|
// Focus ring
|
||||||
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
|
"focus-visible:border-ring focus-visible:ring-ring focus-visible:ring-[3px]",
|
||||||
// Error state ring
|
// 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 state
|
||||||
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
className
|
className
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function RadioGroupItem({
|
|||||||
<RadioGroupPrimitive.Item
|
<RadioGroupPrimitive.Item
|
||||||
data-slot="radio-group-item"
|
data-slot="radio-group-item"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}>
|
{...props}>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function Textarea({ className, dir = "auto", ...props }: TextareaProps): React.J
|
|||||||
style={{ fontSize: "var(--fb-input-font-size)" }}
|
style={{ fontSize: "var(--fb-input-font-size)" }}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"da",
|
"da",
|
||||||
"de",
|
"de",
|
||||||
"es",
|
"es",
|
||||||
|
"et",
|
||||||
"fr",
|
"fr",
|
||||||
"hi",
|
"hi",
|
||||||
"hu",
|
"hu",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ checksums:
|
|||||||
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
common/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
|
||||||
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
|
common/the_servers_cannot_be_reached_at_the_moment: f8adbeccac69f9230a55b5b3af52b081
|
||||||
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
|
common/they_will_be_redirected_immediately: 936bc99cb575cba95ea8f04d82bb353b
|
||||||
|
common/welcome_video: 1f87e84c0a563c2522eef5cb71a1f95c
|
||||||
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
|
common/your_feedback_is_stuck: db2b6aba26723b01aee0fc918d3ca052
|
||||||
errors/all_options_must_be_ranked: 360a2edff623496f7047907bad115ea1
|
errors/all_options_must_be_ranked: 360a2edff623496f7047907bad115ea1
|
||||||
errors/all_rows_must_be_answered: 295f41a0ef04cbb3491c798053c61abd
|
errors/all_rows_must_be_answered: 295f41a0ef04cbb3491c798053c61abd
|
||||||
|
|||||||
@@ -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": "فيديو بطاقة الترحيب",
|
||||||
"your_feedback_is_stuck": "تعليقك عالق :("
|
"your_feedback_is_stuck": "تعليقك عالق :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Vilkår for brug",
|
"terms_of_service": "Vilkår for brug",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Serverne kan ikke kontaktes lige nu.",
|
"the_servers_cannot_be_reached_at_the_moment": "Serverne kan ikke kontaktes lige nu.",
|
||||||
"they_will_be_redirected_immediately": "De bliver straks omdirigeret",
|
"they_will_be_redirected_immediately": "De bliver straks omdirigeret",
|
||||||
|
"welcome_video": "Velkomstkortvideo",
|
||||||
"your_feedback_is_stuck": "Din feedback sidder fast :("
|
"your_feedback_is_stuck": "Din feedback sidder fast :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Nutzungsbedingungen",
|
"terms_of_service": "Nutzungsbedingungen",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
|
"the_servers_cannot_be_reached_at_the_moment": "Die Server sind momentan nicht erreichbar.",
|
||||||
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
|
"they_will_be_redirected_immediately": "Sie werden sofort weitergeleitet",
|
||||||
|
"welcome_video": "Willkommenskarten-Video",
|
||||||
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
|
"your_feedback_is_stuck": "Ihr Feedback steckt fest :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Terms of Service",
|
"terms_of_service": "Terms of Service",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "The servers cannot be reached at the moment.",
|
"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",
|
"they_will_be_redirected_immediately": "They will be redirected immediately",
|
||||||
|
"welcome_video": "Welcome Card video",
|
||||||
"your_feedback_is_stuck": "Your feedback is stuck :("
|
"your_feedback_is_stuck": "Your feedback is stuck :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Términos de servicio",
|
"terms_of_service": "Términos de servicio",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Los servidores no pueden ser alcanzados en este momento.",
|
"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",
|
"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 :("
|
"your_feedback_is_stuck": "Tu feedback está atascado :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Teenusetingimused",
|
"terms_of_service": "Teenusetingimused",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
|
"the_servers_cannot_be_reached_at_the_moment": "Serveritega ei saa hetkel ühendust.",
|
||||||
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
|
"they_will_be_redirected_immediately": "Nad suunatakse kohe ümber",
|
||||||
|
"welcome_video": "Tervituskaardi video",
|
||||||
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
|
"your_feedback_is_stuck": "Sinu tagasiside on kinni jäänud :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Conditions d'utilisation",
|
"terms_of_service": "Conditions d'utilisation",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Les serveurs ne sont pas accessibles pour le moment.",
|
"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",
|
"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é :("
|
"your_feedback_is_stuck": "Votre feedback est bloqué :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"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": "स्वागत कार्ड वीडियो",
|
||||||
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
|
"your_feedback_is_stuck": "आपकी प्रतिक्रिया अटक गई है :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Használati feltételek",
|
"terms_of_service": "Használati feltételek",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Jelenleg nem lehet elérni a kiszolgálókat.",
|
"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",
|
"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 :("
|
"your_feedback_is_stuck": "A visszajelzése elakadt :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Termini di servizio",
|
"terms_of_service": "Termini di servizio",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "I server non sono raggiungibili al momento.",
|
"the_servers_cannot_be_reached_at_the_moment": "I server non sono raggiungibili al momento.",
|
||||||
"they_will_be_redirected_immediately": "Saranno reindirizzati immediatamente",
|
"they_will_be_redirected_immediately": "Saranno reindirizzati immediatamente",
|
||||||
|
"welcome_video": "Video della scheda di benvenuto",
|
||||||
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
|
"your_feedback_is_stuck": "Il tuo feedback è bloccato :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"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": "ウェルカムカード動画",
|
||||||
"your_feedback_is_stuck": "フィードバックが送信できません :("
|
"your_feedback_is_stuck": "フィードバックが送信できません :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Servicevoorwaarden",
|
"terms_of_service": "Servicevoorwaarden",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "De servers zijn momenteel niet bereikbaar.",
|
"the_servers_cannot_be_reached_at_the_moment": "De servers zijn momenteel niet bereikbaar.",
|
||||||
"they_will_be_redirected_immediately": "Ze worden onmiddellijk doorgestuurd",
|
"they_will_be_redirected_immediately": "Ze worden onmiddellijk doorgestuurd",
|
||||||
|
"welcome_video": "Welkomstkaart video",
|
||||||
"your_feedback_is_stuck": "Je feedback blijft hangen :("
|
"your_feedback_is_stuck": "Je feedback blijft hangen :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Termos de serviço",
|
"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.",
|
"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",
|
"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 :("
|
"your_feedback_is_stuck": "Seu feedback está preso :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Termeni și condiții",
|
"terms_of_service": "Termeni și condiții",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Serverele nu pot fi accesate momentan.",
|
"the_servers_cannot_be_reached_at_the_moment": "Serverele nu pot fi accesate momentan.",
|
||||||
"they_will_be_redirected_immediately": "Vor fi redirecționați imediat",
|
"they_will_be_redirected_immediately": "Vor fi redirecționați imediat",
|
||||||
|
"welcome_video": "Videoclip Card de bun venit",
|
||||||
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
|
"your_feedback_is_stuck": "Feedback-ul tău este blocat :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"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": "Видео приветственной карточки",
|
||||||
"your_feedback_is_stuck": "Ваш отзыв застрял :("
|
"your_feedback_is_stuck": "Ваш отзыв застрял :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Användarvillkor",
|
"terms_of_service": "Användarvillkor",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
|
"the_servers_cannot_be_reached_at_the_moment": "Servrarna kan inte nås för tillfället.",
|
||||||
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
|
"they_will_be_redirected_immediately": "De kommer att omdirigeras omedelbart",
|
||||||
|
"welcome_video": "Välkomstkortvideo",
|
||||||
"your_feedback_is_stuck": "Din feedback fastnade :("
|
"your_feedback_is_stuck": "Din feedback fastnade :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"terms_of_service": "Xizmat ko'rsatish shartlari",
|
"terms_of_service": "Xizmat ko'rsatish shartlari",
|
||||||
"the_servers_cannot_be_reached_at_the_moment": "Hozirda serverlarga ulanish imkoni yo'q.",
|
"the_servers_cannot_be_reached_at_the_moment": "Hozirda serverlarga ulanish imkoni yo'q.",
|
||||||
"they_will_be_redirected_immediately": "Ular darhol yo'naltiriladi",
|
"they_will_be_redirected_immediately": "Ular darhol yo'naltiriladi",
|
||||||
|
"welcome_video": "Xush kelibsiz kartasi videosi",
|
||||||
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
|
"your_feedback_is_stuck": "Sizning fikr-mulohazangiz qotib qoldi :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"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": "欢迎卡片视频",
|
||||||
"your_feedback_is_stuck": "您的反馈卡住了 :("
|
"your_feedback_is_stuck": "您的反馈卡住了 :("
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
|||||||
@@ -49,7 +49,8 @@
|
|||||||
"i18next-icu": "2.4.3",
|
"i18next-icu": "2.4.3",
|
||||||
"isomorphic-dompurify": "3.1.0",
|
"isomorphic-dompurify": "3.1.0",
|
||||||
"preact": "10.29.0",
|
"preact": "10.29.0",
|
||||||
"react-i18next": "16.5.8"
|
"react-i18next": "16.5.8",
|
||||||
|
"tailwind-merge": "3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@formbricks/config-typescript": "workspace:*",
|
"@formbricks/config-typescript": "workspace:*",
|
||||||
|
|||||||
@@ -169,8 +169,7 @@ export function MultipleChoiceMultiElement({
|
|||||||
setOtherValue(newOtherValue);
|
setOtherValue(newOtherValue);
|
||||||
const baseLabels = getNormalizedSelectedLabels();
|
const baseLabels = getNormalizedSelectedLabels();
|
||||||
|
|
||||||
const nextValue = [...baseLabels, ""];
|
const nextValue = [...baseLabels, newOtherValue];
|
||||||
if (newOtherValue.trim()) nextValue.push(newOtherValue);
|
|
||||||
|
|
||||||
onChange({ [element.id]: nextValue });
|
onChange({ [element.id]: nextValue });
|
||||||
};
|
};
|
||||||
@@ -227,8 +226,7 @@ export function MultipleChoiceMultiElement({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (isOtherNowSelected) {
|
if (isOtherNowSelected) {
|
||||||
nextLabels.push("");
|
nextLabels.push(otherValue);
|
||||||
if (otherValue.trim()) nextLabels.push(otherValue);
|
|
||||||
} else if (otherValue) {
|
} else if (otherValue) {
|
||||||
// If other was deselected, clear any stale other value
|
// If other was deselected, clear any stale other value
|
||||||
setOtherValue("");
|
setOtherValue("");
|
||||||
|
|||||||
@@ -23,15 +23,16 @@ interface ElementMediaProps {
|
|||||||
imgUrl?: string;
|
imgUrl?: string;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
altText?: string;
|
altText?: string;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: ElementMediaProps) {
|
export function ElementMedia({ imgUrl, videoUrl, altText = "Image", className }: ElementMediaProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
|
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group/image relative mb-6 block min-h-40 rounded-md">
|
<div className={cn("group/image relative mb-6 block min-h-40 rounded-md", className)}>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="absolute inset-auto flex h-full w-full animate-pulse items-center justify-center rounded-md bg-slate-200" />
|
<div className="absolute inset-auto flex h-full w-full animate-pulse items-center justify-center rounded-md bg-slate-200" />
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
type SerializedSurveyState,
|
type SerializedSurveyState,
|
||||||
clearSurveyProgress,
|
clearSurveyProgress,
|
||||||
getSurveyProgress,
|
getSurveyProgress,
|
||||||
|
patchSurveyProgressSnapshot,
|
||||||
saveSurveyProgress,
|
saveSurveyProgress,
|
||||||
} from "@/lib/offline-storage";
|
} from "@/lib/offline-storage";
|
||||||
import { parseRecallInformation } from "@/lib/recall";
|
import { parseRecallInformation } from "@/lib/recall";
|
||||||
@@ -38,13 +39,28 @@ import { useOnlineStatus } from "@/lib/use-online-status";
|
|||||||
import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils";
|
import { cn, findBlockByElementId, getDefaultLanguageCode, getElementsFromSurveyBlocks } from "@/lib/utils";
|
||||||
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
|
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
|
||||||
|
|
||||||
const restoreSurveyStateFromSnapshot = (surveyState: SurveyState, snapshot: SerializedSurveyState): void => {
|
const restoreSurveyStateFromSnapshot = (
|
||||||
|
surveyState: SurveyState,
|
||||||
|
snapshot: SerializedSurveyState,
|
||||||
|
progress: {
|
||||||
|
responseData: TResponseData;
|
||||||
|
ttc: TResponseTtc;
|
||||||
|
currentVariables: TResponseVariables;
|
||||||
|
}
|
||||||
|
): void => {
|
||||||
if (snapshot.responseId) surveyState.updateResponseId(snapshot.responseId);
|
if (snapshot.responseId) surveyState.updateResponseId(snapshot.responseId);
|
||||||
if (snapshot.displayId) surveyState.updateDisplayId(snapshot.displayId);
|
if (snapshot.displayId) surveyState.updateDisplayId(snapshot.displayId);
|
||||||
if (snapshot.userId) surveyState.updateUserId(snapshot.userId);
|
if (snapshot.userId) surveyState.updateUserId(snapshot.userId);
|
||||||
if (snapshot.contactId) surveyState.updateContactId(snapshot.contactId);
|
if (snapshot.contactId) surveyState.updateContactId(snapshot.contactId);
|
||||||
if (snapshot.singleUseId) surveyState.singleUseId = snapshot.singleUseId;
|
if (snapshot.singleUseId) surveyState.singleUseId = snapshot.singleUseId;
|
||||||
surveyState.responseAcc = snapshot.responseAcc;
|
surveyState.disableBootstrapResponseCreate();
|
||||||
|
surveyState.responseAcc = {
|
||||||
|
...snapshot.responseAcc,
|
||||||
|
data: progress.responseData,
|
||||||
|
ttc: progress.ttc,
|
||||||
|
variables: progress.currentVariables,
|
||||||
|
displayId: snapshot.displayId ?? snapshot.responseAcc.displayId,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface VariableStackEntry {
|
interface VariableStackEntry {
|
||||||
@@ -127,6 +143,14 @@ export function Survey({
|
|||||||
const offlinePersistEnabled =
|
const offlinePersistEnabled =
|
||||||
offlineSupport && isLinkSurvey && !isPreviewMode && !!appUrl && !!environmentId;
|
offlineSupport && isLinkSurvey && !isPreviewMode && !!appUrl && !!environmentId;
|
||||||
|
|
||||||
|
const persistSurveyStateSnapshot = useCallback(
|
||||||
|
async (snapshotPatch: Partial<SerializedSurveyState>) => {
|
||||||
|
if (!offlinePersistEnabled) return;
|
||||||
|
await patchSurveyProgressSnapshot(survey.id, snapshotPatch);
|
||||||
|
},
|
||||||
|
[offlinePersistEnabled, survey.id]
|
||||||
|
);
|
||||||
|
|
||||||
const responseQueue = useMemo(() => {
|
const responseQueue = useMemo(() => {
|
||||||
if (appUrl && environmentId && surveyState) {
|
if (appUrl && environmentId && surveyState) {
|
||||||
return new ResponseQueue(
|
return new ResponseQueue(
|
||||||
@@ -160,6 +184,9 @@ export function Survey({
|
|||||||
setBlockId(quotaInfo.endingCardId);
|
setBlockId(quotaInfo.endingCardId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onResponseCreated: (responseId) => {
|
||||||
|
void persistSurveyStateSnapshot({ responseId });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
surveyState
|
surveyState
|
||||||
);
|
);
|
||||||
@@ -173,6 +200,7 @@ export function Survey({
|
|||||||
getSetIsResponseSendingFinished,
|
getSetIsResponseSendingFinished,
|
||||||
surveyState,
|
surveyState,
|
||||||
offlinePersistEnabled,
|
offlinePersistEnabled,
|
||||||
|
persistSurveyStateSnapshot,
|
||||||
survey.id,
|
survey.id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -319,6 +347,7 @@ export function Survey({
|
|||||||
|
|
||||||
surveyState.updateDisplayId(display.data.id);
|
surveyState.updateDisplayId(display.data.id);
|
||||||
responseQueue.updateSurveyState(surveyState);
|
responseQueue.updateSurveyState(surveyState);
|
||||||
|
await persistSurveyStateSnapshot({ displayId: display.data.id });
|
||||||
|
|
||||||
if (onDisplayCreated) {
|
if (onDisplayCreated) {
|
||||||
onDisplayCreated();
|
onDisplayCreated();
|
||||||
@@ -337,6 +366,7 @@ export function Survey({
|
|||||||
onDisplayCreated,
|
onDisplayCreated,
|
||||||
isPreviewMode,
|
isPreviewMode,
|
||||||
onDisplay,
|
onDisplay,
|
||||||
|
persistSurveyStateSnapshot,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Create display on mount. When offline persistence is enabled, wait for progress
|
// Create display on mount. When offline persistence is enabled, wait for progress
|
||||||
@@ -458,7 +488,36 @@ export function Survey({
|
|||||||
|
|
||||||
// Restore survey state from snapshot
|
// Restore survey state from snapshot
|
||||||
if (surveyState && progress.surveyStateSnapshot) {
|
if (surveyState && progress.surveyStateSnapshot) {
|
||||||
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
|
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
|
||||||
|
|
||||||
|
if (pendingCount === 0 && !progress.surveyStateSnapshot.responseId) {
|
||||||
|
if (progress.surveyStateSnapshot.displayId && apiClient) {
|
||||||
|
const responseLookup = await apiClient.getResponseIdByDisplayId(
|
||||||
|
progress.surveyStateSnapshot.displayId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (responseLookup.ok && responseLookup.data.responseId) {
|
||||||
|
surveyState.updateResponseId(responseLookup.data.responseId);
|
||||||
|
await persistSurveyStateSnapshot({ responseId: responseLookup.data.responseId });
|
||||||
|
} else if (responseLookup.ok) {
|
||||||
|
surveyState.enableBootstrapResponseCreate();
|
||||||
|
} else if (responseLookup.error.status === 404) {
|
||||||
|
surveyState.updateDisplayId(null);
|
||||||
|
surveyState.enableBootstrapResponseCreate();
|
||||||
|
await persistSurveyStateSnapshot({ displayId: null });
|
||||||
|
} else {
|
||||||
|
console.error("Formbricks: Failed to recover responseId from displayId", {
|
||||||
|
displayId: progress.surveyStateSnapshot.displayId,
|
||||||
|
error: responseLookup.error,
|
||||||
|
});
|
||||||
|
surveyState.enableBootstrapResponseCreate();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
surveyState.enableBootstrapResponseCreate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responseQueue?.updateSurveyState(surveyState);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Block no longer exists (survey structure changed) — discard UI progress
|
// Block no longer exists (survey structure changed) — discard UI progress
|
||||||
@@ -466,7 +525,8 @@ export function Survey({
|
|||||||
await clearSurveyProgress(survey.id);
|
await clearSurveyProgress(survey.id);
|
||||||
|
|
||||||
if (surveyState && progress.surveyStateSnapshot) {
|
if (surveyState && progress.surveyStateSnapshot) {
|
||||||
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot);
|
restoreSurveyStateFromSnapshot(surveyState, progress.surveyStateSnapshot, progress);
|
||||||
|
responseQueue?.updateSurveyState(surveyState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,8 +599,8 @@ export function Survey({
|
|||||||
// --- Warn before leaving mid-survey or with unsent offline responses ---
|
// --- Warn before leaving mid-survey or with unsent offline responses ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
// Warn if user has started answering but hasn't finished the survey
|
// Warn if user has started answering but hasn't finished the survey (only when offline support is active)
|
||||||
if (history.length > 0 && !isSurveyFinished) {
|
if (offlinePersistEnabled && history.length > 0 && !isSurveyFinished) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,8 +147,10 @@ export function WelcomeCard({
|
|||||||
return (
|
return (
|
||||||
<ScrollableContainer fullSizeCards={fullSizeCards}>
|
<ScrollableContainer fullSizeCards={fullSizeCards}>
|
||||||
<div>
|
<div>
|
||||||
{fileUrl || videoUrl ? (
|
{fileUrl ? (
|
||||||
<ElementMedia imgUrl={fileUrl} videoUrl={videoUrl} altText={t("common.company_logo")} />
|
<ElementMedia imgUrl={fileUrl} altText={t("common.company_logo")} className="mb-8 min-h-0 w-1/4" />
|
||||||
|
) : videoUrl ? (
|
||||||
|
<ElementMedia videoUrl={videoUrl} altText={t("common.welcome_video")} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<Headline
|
<Headline
|
||||||
|
|||||||
@@ -46,6 +46,16 @@ export class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getResponseIdByDisplayId(
|
||||||
|
displayId: string
|
||||||
|
): Promise<Result<{ responseId: string | null }, ApiErrorResponse>> {
|
||||||
|
return makeRequest(
|
||||||
|
this.appUrl,
|
||||||
|
`/api/v1/client/${this.environmentId}/displays/${displayId}/response`,
|
||||||
|
"GET"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async createResponse(
|
async createResponse(
|
||||||
responseInput: Omit<TResponseInput, "environmentId"> & {
|
responseInput: Omit<TResponseInput, "environmentId"> & {
|
||||||
contactId: string | null;
|
contactId: string | null;
|
||||||
|
|||||||
@@ -241,6 +241,44 @@ export const getSurveyProgress = async (surveyId: string): Promise<SurveyProgres
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const patchSurveyProgressSnapshot = async (
|
||||||
|
surveyId: string,
|
||||||
|
snapshotPatch: Partial<SerializedSurveyState>
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE_SURVEY_PROGRESS, "readwrite");
|
||||||
|
const store = tx.objectStore(STORE_SURVEY_PROGRESS);
|
||||||
|
const getRequest = store.get(surveyId);
|
||||||
|
|
||||||
|
getRequest.onsuccess = () => {
|
||||||
|
const existing = getRequest.result as SurveyProgressEntry | undefined;
|
||||||
|
if (!existing) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const putRequest = store.put({
|
||||||
|
...existing,
|
||||||
|
surveyStateSnapshot: {
|
||||||
|
...existing.surveyStateSnapshot,
|
||||||
|
...snapshotPatch,
|
||||||
|
},
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
putRequest.onsuccess = () => resolve();
|
||||||
|
putRequest.onerror = () => reject(putRequest.error ?? new Error("IndexedDB request failed"));
|
||||||
|
};
|
||||||
|
|
||||||
|
getRequest.onerror = () => reject(getRequest.error ?? new Error("IndexedDB request failed"));
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Formbricks: Failed to patch survey progress snapshot in IndexedDB", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const clearSurveyProgress = async (surveyId: string): Promise<void> => {
|
export const clearSurveyProgress = async (surveyId: string): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface QueueConfig {
|
|||||||
retryAttempts: number;
|
retryAttempts: number;
|
||||||
persistOffline?: boolean;
|
persistOffline?: boolean;
|
||||||
surveyId?: string;
|
surveyId?: string;
|
||||||
|
onResponseCreated?: (responseId: string) => void;
|
||||||
onResponseSendingFailed?: (responseUpdate: TResponseUpdate, errorCode?: TResponseErrorCodesEnum) => void;
|
onResponseSendingFailed?: (responseUpdate: TResponseUpdate, errorCode?: TResponseErrorCodesEnum) => void;
|
||||||
onResponseSendingFinished?: () => void;
|
onResponseSendingFinished?: () => void;
|
||||||
onQuotaFull?: (quotaInfo: TQuotaFullResponse) => void;
|
onQuotaFull?: (quotaInfo: TQuotaFullResponse) => void;
|
||||||
@@ -32,16 +33,32 @@ export const delay = (ms: number): Promise<void> => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Module-level locks keyed by surveyId.
|
||||||
|
// Survive ResponseQueue instance recreation (e.g. React useMemo recomputation)
|
||||||
|
// so that only one sync/send runs at a time per survey, even across instances.
|
||||||
|
const syncingBySurvey = new Map<string, boolean>();
|
||||||
|
const requestInProgressBySurvey = new Map<string, boolean>();
|
||||||
|
|
||||||
|
/** @internal Exposed for tests only. */
|
||||||
|
export const _syncLocks = {
|
||||||
|
clear: () => {
|
||||||
|
syncingBySurvey.clear();
|
||||||
|
requestInProgressBySurvey.clear();
|
||||||
|
},
|
||||||
|
set: (surveyId: string, value: boolean) => syncingBySurvey.set(surveyId, value),
|
||||||
|
get: (surveyId: string) => syncingBySurvey.get(surveyId) ?? false,
|
||||||
|
setRequestInProgress: (surveyId: string, value: boolean) => requestInProgressBySurvey.set(surveyId, value),
|
||||||
|
getRequestInProgress: (surveyId: string) => requestInProgressBySurvey.get(surveyId) ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
export class ResponseQueue {
|
export class ResponseQueue {
|
||||||
readonly queue: TResponseUpdate[] = [];
|
readonly queue: TResponseUpdate[] = [];
|
||||||
readonly config: QueueConfig;
|
readonly config: QueueConfig;
|
||||||
private surveyState: SurveyState;
|
private surveyState: SurveyState;
|
||||||
private isRequestInProgress = false;
|
|
||||||
readonly api: ApiClient;
|
readonly api: ApiClient;
|
||||||
private responseRecaptchaToken?: string;
|
private responseRecaptchaToken?: string;
|
||||||
// Maps in-memory queue index → IndexedDB id for cleanup after successful send
|
// Maps in-memory queue index → IndexedDB id for cleanup after successful send
|
||||||
private readonly pendingDbIds: Map<TResponseUpdate, number> = new Map();
|
private readonly pendingDbIds: Map<TResponseUpdate, number> = new Map();
|
||||||
private isSyncing = false;
|
|
||||||
|
|
||||||
constructor(config: QueueConfig, surveyState: SurveyState) {
|
constructor(config: QueueConfig, surveyState: SurveyState) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -52,6 +69,26 @@ export class ResponseQueue {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private get isSyncing(): boolean {
|
||||||
|
return this.config.surveyId ? (syncingBySurvey.get(this.config.surveyId) ?? false) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private set isSyncing(value: boolean) {
|
||||||
|
if (this.config.surveyId) {
|
||||||
|
syncingBySurvey.set(this.config.surveyId, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get isRequestInProgress(): boolean {
|
||||||
|
return this.config.surveyId ? (requestInProgressBySurvey.get(this.config.surveyId) ?? false) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private set isRequestInProgress(value: boolean) {
|
||||||
|
if (this.config.surveyId) {
|
||||||
|
requestInProgressBySurvey.set(this.config.surveyId, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setResponseRecaptchaToken(token?: string) {
|
setResponseRecaptchaToken(token?: string) {
|
||||||
this.responseRecaptchaToken = token;
|
this.responseRecaptchaToken = token;
|
||||||
}
|
}
|
||||||
@@ -111,8 +148,26 @@ export class ResponseQueue {
|
|||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isRequestInProgress = true;
|
// When offline support is active and there are multiple pending entries in
|
||||||
|
// IndexedDB, defer to syncPersistedResponses which drains them in order.
|
||||||
|
// This prevents processQueue and syncPersistedResponses from racing to
|
||||||
|
// create the same response concurrently (duplicate POSTs).
|
||||||
|
if (this.config.persistOffline && this.config.surveyId) {
|
||||||
|
const pendingCount = await countPendingResponses(this.config.surveyId);
|
||||||
|
|
||||||
|
// Re-check after await — another processQueue/sync may have started during the yield
|
||||||
|
if (this.isSyncing || this.isRequestInProgress || this.queue.length === 0) {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingCount > 1) {
|
||||||
|
void this.syncPersistedResponses();
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const responseUpdate = this.queue[0];
|
const responseUpdate = this.queue[0];
|
||||||
|
this.isRequestInProgress = true;
|
||||||
|
|
||||||
const result = await this.sendResponseWithRetry(responseUpdate);
|
const result = await this.sendResponseWithRetry(responseUpdate);
|
||||||
|
|
||||||
@@ -169,6 +224,11 @@ export class ResponseQueue {
|
|||||||
|
|
||||||
// Concurrency guard: prevent duplicate syncs from online/offline flicker
|
// Concurrency guard: prevent duplicate syncs from online/offline flicker
|
||||||
if (this.isSyncing) return { success: false, syncedCount: 0 };
|
if (this.isSyncing) return { success: false, syncedCount: 0 };
|
||||||
|
|
||||||
|
// If processQueue already has a request in flight, don't start syncing —
|
||||||
|
// let it finish first to avoid both paths creating the same response.
|
||||||
|
if (this.isRequestInProgress) return { success: false, syncedCount: 0 };
|
||||||
|
|
||||||
this.isSyncing = true;
|
this.isSyncing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -300,6 +360,37 @@ export class ResponseQueue {
|
|||||||
return error.details?.code === RECAPTCHA_VERIFICATION_ERROR_CODE;
|
return error.details?.code === RECAPTCHA_VERIFICATION_ERROR_CODE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getCreatePayload(
|
||||||
|
responseUpdate: TResponseUpdate
|
||||||
|
): Omit<
|
||||||
|
Parameters<ApiClient["createResponse"]>[0],
|
||||||
|
"contactId" | "userId" | "singleUseId" | "surveyId" | "displayId" | "recaptchaToken"
|
||||||
|
> {
|
||||||
|
if (!this.surveyState.shouldCreateResponseFromState) {
|
||||||
|
return {
|
||||||
|
finished: responseUpdate.finished,
|
||||||
|
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
|
||||||
|
ttc: responseUpdate.ttc,
|
||||||
|
variables: responseUpdate.variables,
|
||||||
|
language: responseUpdate.language,
|
||||||
|
meta: responseUpdate.meta,
|
||||||
|
endingId: responseUpdate.endingId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const accumulatedResponse = this.surveyState.responseAcc;
|
||||||
|
|
||||||
|
return {
|
||||||
|
finished: accumulatedResponse.finished,
|
||||||
|
data: { ...accumulatedResponse.data, ...responseUpdate.hiddenFields },
|
||||||
|
ttc: accumulatedResponse.ttc,
|
||||||
|
variables: accumulatedResponse.variables,
|
||||||
|
language: accumulatedResponse.language ?? responseUpdate.language,
|
||||||
|
meta: accumulatedResponse.meta ?? responseUpdate.meta,
|
||||||
|
endingId: accumulatedResponse.endingId ?? responseUpdate.endingId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private handleSuccessfulResponse(responseUpdate: TResponseUpdate, quotaFullResponse?: TQuotaFullResponse) {
|
private handleSuccessfulResponse(responseUpdate: TResponseUpdate, quotaFullResponse?: TQuotaFullResponse) {
|
||||||
if (responseUpdate.finished) {
|
if (responseUpdate.finished) {
|
||||||
this.config.onResponseSendingFinished?.();
|
this.config.onResponseSendingFinished?.();
|
||||||
@@ -340,13 +431,13 @@ export class ResponseQueue {
|
|||||||
return err(response.error);
|
return err(response.error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
const createPayload = this.getCreatePayload(responseUpdate);
|
||||||
response = await this.api.createResponse({
|
response = await this.api.createResponse({
|
||||||
...responseUpdate,
|
...createPayload,
|
||||||
surveyId: this.surveyState.surveyId,
|
surveyId: this.surveyState.surveyId,
|
||||||
contactId: this.surveyState.contactId || null,
|
contactId: this.surveyState.contactId || null,
|
||||||
userId: this.surveyState.userId || null,
|
userId: this.surveyState.userId || null,
|
||||||
singleUseId: this.surveyState.singleUseId || null,
|
singleUseId: this.surveyState.singleUseId || null,
|
||||||
data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
|
|
||||||
displayId: this.surveyState.displayId,
|
displayId: this.surveyState.displayId,
|
||||||
recaptchaToken: this.responseRecaptchaToken ?? undefined,
|
recaptchaToken: this.responseRecaptchaToken ?? undefined,
|
||||||
});
|
});
|
||||||
@@ -356,6 +447,8 @@ export class ResponseQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.surveyState.updateResponseId(response.data.id);
|
this.surveyState.updateResponseId(response.data.id);
|
||||||
|
this.surveyState.disableBootstrapResponseCreate();
|
||||||
|
this.config.onResponseCreated?.(response.data.id);
|
||||||
if (this.config.setSurveyState) {
|
if (this.config.setSurveyState) {
|
||||||
this.config.setSurveyState(this.surveyState);
|
this.config.setSurveyState(this.surveyState);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vit
|
|||||||
import { err, ok } from "@formbricks/types/error-handlers";
|
import { err, ok } from "@formbricks/types/error-handlers";
|
||||||
import { TResponseUpdate } from "@formbricks/types/responses";
|
import { TResponseUpdate } from "@formbricks/types/responses";
|
||||||
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
|
import { TResponseErrorCodesEnum } from "@/types/response-error-codes";
|
||||||
import { ResponseQueue, delay } from "./response-queue";
|
import { ResponseQueue, _syncLocks, delay } from "./response-queue";
|
||||||
import { SurveyState } from "./survey-state";
|
import { SurveyState } from "./survey-state";
|
||||||
|
|
||||||
// Suppress noisy console output from retry logic during tests
|
// Suppress noisy console output from retry logic during tests
|
||||||
@@ -38,11 +38,14 @@ const getSurveyState: () => SurveyState = () => ({
|
|||||||
contactId: "contact1",
|
contactId: "contact1",
|
||||||
surveyId: "survey1",
|
surveyId: "survey1",
|
||||||
singleUseId: "single1",
|
singleUseId: "single1",
|
||||||
|
shouldCreateResponseFromState: false,
|
||||||
responseAcc: { finished: false, data: {}, ttc: {}, variables: {} },
|
responseAcc: { finished: false, data: {}, ttc: {}, variables: {} },
|
||||||
updateResponseId: vi.fn(),
|
updateResponseId: vi.fn(),
|
||||||
updateDisplayId: vi.fn(),
|
updateDisplayId: vi.fn(),
|
||||||
updateUserId: vi.fn(),
|
updateUserId: vi.fn(),
|
||||||
updateContactId: vi.fn(),
|
updateContactId: vi.fn(),
|
||||||
|
enableBootstrapResponseCreate: vi.fn(),
|
||||||
|
disableBootstrapResponseCreate: vi.fn(),
|
||||||
accumulateResponse: vi.fn(),
|
accumulateResponse: vi.fn(),
|
||||||
isResponseFinished: vi.fn(),
|
isResponseFinished: vi.fn(),
|
||||||
clear: vi.fn(),
|
clear: vi.fn(),
|
||||||
@@ -86,6 +89,7 @@ describe("ResponseQueue", () => {
|
|||||||
queue = new ResponseQueue(config, surveyState);
|
queue = new ResponseQueue(config, surveyState);
|
||||||
apiMock = queue.api;
|
apiMock = queue.api;
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
_syncLocks.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("constructor initializes properties", () => {
|
test("constructor initializes properties", () => {
|
||||||
@@ -110,26 +114,30 @@ describe("ResponseQueue", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("processQueue does nothing if request in progress or queue empty", async () => {
|
test("processQueue does nothing if request in progress or queue empty", async () => {
|
||||||
queue["isRequestInProgress"] = true;
|
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
|
||||||
await queue.processQueue();
|
_syncLocks.setRequestInProgress("s1", true);
|
||||||
queue["isRequestInProgress"] = false;
|
await reqQueue.processQueue();
|
||||||
queue.queue.length = 0;
|
_syncLocks.setRequestInProgress("s1", false);
|
||||||
await queue.processQueue();
|
reqQueue.queue.length = 0;
|
||||||
|
await reqQueue.processQueue();
|
||||||
expect(true).toBe(true); // just to ensure no errors
|
expect(true).toBe(true); // just to ensure no errors
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processQueue sends response and removes from queue on success", async () => {
|
test("processQueue sends response and removes from queue on success", async () => {
|
||||||
queue.queue.push(responseUpdate);
|
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
|
||||||
vi.spyOn(queue, "sendResponse").mockResolvedValue(ok(true));
|
reqQueue.queue.push(responseUpdate);
|
||||||
await queue.processQueue();
|
vi.spyOn(reqQueue, "sendResponse").mockResolvedValue(ok(true));
|
||||||
expect(queue.queue.length).toBe(0);
|
await reqQueue.processQueue();
|
||||||
expect(queue["isRequestInProgress"]).toBe(false);
|
expect(reqQueue.queue.length).toBe(0);
|
||||||
|
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processQueue retries and calls onResponseSendingFailed on recaptcha error", async () => {
|
test("processQueue retries and calls onResponseSendingFailed on recaptcha error", async () => {
|
||||||
queue.queue.push(responseUpdate);
|
const recaptchaConfig = getConfig({ surveyId: "s1" });
|
||||||
|
const recaptchaQueue = new ResponseQueue(recaptchaConfig, getSurveyState());
|
||||||
|
recaptchaQueue.queue.push(responseUpdate);
|
||||||
|
|
||||||
vi.spyOn(queue, "sendResponse").mockResolvedValue(
|
vi.spyOn(recaptchaQueue, "sendResponse").mockResolvedValue(
|
||||||
err({
|
err({
|
||||||
code: "internal_server_error",
|
code: "internal_server_error",
|
||||||
message: "An error occurred while sending the response.",
|
message: "An error occurred while sending the response.",
|
||||||
@@ -139,29 +147,31 @@ describe("ResponseQueue", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await queue.processQueue();
|
await recaptchaQueue.processQueue();
|
||||||
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
|
expect(recaptchaConfig.onResponseSendingFailed).toHaveBeenCalledWith(
|
||||||
responseUpdate,
|
responseUpdate,
|
||||||
TResponseErrorCodesEnum.RecaptchaError
|
TResponseErrorCodesEnum.RecaptchaError
|
||||||
);
|
);
|
||||||
expect(queue["isRequestInProgress"]).toBe(false);
|
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processQueue retries and calls onResponseSendingFailed after max attempts", async () => {
|
test("processQueue retries and calls onResponseSendingFailed after max attempts", async () => {
|
||||||
queue.queue.push(responseUpdate);
|
const reqConfig = getConfig({ surveyId: "s1" });
|
||||||
vi.spyOn(queue, "sendResponse").mockResolvedValue(
|
const reqQueue = new ResponseQueue(reqConfig, getSurveyState());
|
||||||
|
reqQueue.queue.push(responseUpdate);
|
||||||
|
vi.spyOn(reqQueue, "sendResponse").mockResolvedValue(
|
||||||
err({
|
err({
|
||||||
code: "internal_server_error",
|
code: "internal_server_error",
|
||||||
message: "An error occurred while sending the response.",
|
message: "An error occurred while sending the response.",
|
||||||
status: 500,
|
status: 500,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await queue.processQueue();
|
await reqQueue.processQueue();
|
||||||
expect(config.onResponseSendingFailed).toHaveBeenCalledWith(
|
expect(reqConfig.onResponseSendingFailed).toHaveBeenCalledWith(
|
||||||
responseUpdate,
|
responseUpdate,
|
||||||
TResponseErrorCodesEnum.ResponseSendingError
|
TResponseErrorCodesEnum.ResponseSendingError
|
||||||
);
|
);
|
||||||
expect(queue["isRequestInProgress"]).toBe(false);
|
expect(_syncLocks.getRequestInProgress("s1")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("processQueue calls onResponseSendingFinished if finished", async () => {
|
test("processQueue calls onResponseSendingFinished if finished", async () => {
|
||||||
@@ -184,6 +194,7 @@ describe("ResponseQueue", () => {
|
|||||||
const result = await queue.sendResponse(responseUpdate);
|
const result = await queue.sendResponse(responseUpdate);
|
||||||
expect(apiMock.createResponse).toHaveBeenCalled();
|
expect(apiMock.createResponse).toHaveBeenCalled();
|
||||||
expect(surveyState.updateResponseId).toHaveBeenCalledWith("newid");
|
expect(surveyState.updateResponseId).toHaveBeenCalledWith("newid");
|
||||||
|
expect(surveyState.disableBootstrapResponseCreate).toHaveBeenCalled();
|
||||||
expect(config.setSurveyState).toHaveBeenCalledWith(surveyState);
|
expect(config.setSurveyState).toHaveBeenCalledWith(surveyState);
|
||||||
expect(result.ok).toBe(true);
|
expect(result.ok).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -218,8 +229,9 @@ describe("ResponseQueue", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("processQueueAsync returns success false if request in progress", async () => {
|
test("processQueueAsync returns success false if request in progress", async () => {
|
||||||
queue["isRequestInProgress"] = true;
|
const reqQueue = new ResponseQueue(getConfig({ surveyId: "s1" }), getSurveyState());
|
||||||
const result = await queue.processQueue();
|
_syncLocks.setRequestInProgress("s1", true);
|
||||||
|
const result = await reqQueue.processQueue();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -309,9 +321,13 @@ describe("ResponseQueue", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("processQueue returns false when isSyncing is true", async () => {
|
test("processQueue returns false when isSyncing is true", async () => {
|
||||||
queue.queue.push(responseUpdate);
|
const offlineQueue = new ResponseQueue(
|
||||||
queue["isSyncing"] = true;
|
getConfig({ persistOffline: true, surveyId: "s1" }),
|
||||||
const result = await queue.processQueue();
|
getSurveyState()
|
||||||
|
);
|
||||||
|
offlineQueue.queue.push(responseUpdate);
|
||||||
|
_syncLocks.set("s1", true);
|
||||||
|
const result = await offlineQueue.processQueue();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,7 +363,7 @@ describe("ResponseQueue", () => {
|
|||||||
getConfig({ persistOffline: true, surveyId: "s1" }),
|
getConfig({ persistOffline: true, surveyId: "s1" }),
|
||||||
getSurveyState()
|
getSurveyState()
|
||||||
);
|
);
|
||||||
offlineQueue["isSyncing"] = true;
|
_syncLocks.set("s1", true);
|
||||||
const result = await offlineQueue.syncPersistedResponses();
|
const result = await offlineQueue.syncPersistedResponses();
|
||||||
expect(result).toEqual({ success: false, syncedCount: 0 });
|
expect(result).toEqual({ success: false, syncedCount: 0 });
|
||||||
});
|
});
|
||||||
@@ -382,7 +398,7 @@ describe("ResponseQueue", () => {
|
|||||||
expect(result).toEqual({ success: true, syncedCount: 1 });
|
expect(result).toEqual({ success: true, syncedCount: 1 });
|
||||||
expect(removePendingResponse).toHaveBeenCalledWith(10);
|
expect(removePendingResponse).toHaveBeenCalledWith(10);
|
||||||
expect(offlineQueue.queue.length).toBe(0);
|
expect(offlineQueue.queue.length).toBe(0);
|
||||||
expect(offlineQueue["isSyncing"]).toBe(false);
|
expect(_syncLocks.get("s1")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("syncPersistedResponses stops on server error", async () => {
|
test("syncPersistedResponses stops on server error", async () => {
|
||||||
@@ -415,7 +431,7 @@ describe("ResponseQueue", () => {
|
|||||||
|
|
||||||
const result = await offlineQueue.syncPersistedResponses();
|
const result = await offlineQueue.syncPersistedResponses();
|
||||||
expect(result).toEqual({ success: false, syncedCount: 0 });
|
expect(result).toEqual({ success: false, syncedCount: 0 });
|
||||||
expect(offlineQueue["isSyncing"]).toBe(false);
|
expect(_syncLocks.get("s1")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("syncPersistedResponses retries 404 as createResponse by resetting responseId", async () => {
|
test("syncPersistedResponses retries 404 as createResponse by resetting responseId", async () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe("SurveyState", () => {
|
|||||||
expect(surveyState.surveyId).toBe(initialSurveyId);
|
expect(surveyState.surveyId).toBe(initialSurveyId);
|
||||||
expect(surveyState.responseId).toBeNull();
|
expect(surveyState.responseId).toBeNull();
|
||||||
expect(surveyState.displayId).toBeNull();
|
expect(surveyState.displayId).toBeNull();
|
||||||
|
expect(surveyState.shouldCreateResponseFromState).toBe(false);
|
||||||
expect(surveyState.userId).toBeNull();
|
expect(surveyState.userId).toBeNull();
|
||||||
expect(surveyState.contactId).toBeNull();
|
expect(surveyState.contactId).toBeNull();
|
||||||
expect(surveyState.singleUseId).toBeNull();
|
expect(surveyState.singleUseId).toBeNull();
|
||||||
@@ -137,7 +138,7 @@ describe("SurveyState", () => {
|
|||||||
|
|
||||||
expect(surveyState.responseAcc.finished).toBe(true);
|
expect(surveyState.responseAcc.finished).toBe(true);
|
||||||
expect(surveyState.responseAcc.data).toEqual({ q1: "newAns1", q2: "ans2" });
|
expect(surveyState.responseAcc.data).toEqual({ q1: "newAns1", q2: "ans2" });
|
||||||
expect(surveyState.responseAcc.ttc).toEqual({ q2: 200 }); // ttc is overwritten
|
expect(surveyState.responseAcc.ttc).toEqual({ q1: 100, q2: 200 });
|
||||||
expect(surveyState.responseAcc.variables).toEqual({ varB: "valB" }); // variables are overwritten
|
expect(surveyState.responseAcc.variables).toEqual({ varB: "valB" }); // variables are overwritten
|
||||||
expect(surveyState.responseAcc.displayId).toBe("display123");
|
expect(surveyState.responseAcc.displayId).toBe("display123");
|
||||||
});
|
});
|
||||||
@@ -158,9 +159,11 @@ describe("SurveyState", () => {
|
|||||||
describe("clear", () => {
|
describe("clear", () => {
|
||||||
test("should reset responseId and responseAcc", () => {
|
test("should reset responseId and responseAcc", () => {
|
||||||
surveyState.responseId = "someId";
|
surveyState.responseId = "someId";
|
||||||
|
surveyState.enableBootstrapResponseCreate();
|
||||||
surveyState.responseAcc = { finished: true, data: { q: "a" }, ttc: { q: 1 }, variables: { v: "1" } };
|
surveyState.responseAcc = { finished: true, data: { q: "a" }, ttc: { q: 1 }, variables: { v: "1" } };
|
||||||
surveyState.clear();
|
surveyState.clear();
|
||||||
expect(surveyState.responseId).toBeNull();
|
expect(surveyState.responseId).toBeNull();
|
||||||
|
expect(surveyState.shouldCreateResponseFromState).toBe(false);
|
||||||
expect(surveyState.responseAcc).toEqual({ finished: false, data: {}, ttc: {}, variables: {} });
|
expect(surveyState.responseAcc).toEqual({ finished: false, data: {}, ttc: {}, variables: {} });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export class SurveyState {
|
|||||||
userId: string | null = null;
|
userId: string | null = null;
|
||||||
contactId: string | null = null;
|
contactId: string | null = null;
|
||||||
surveyId: string;
|
surveyId: string;
|
||||||
|
shouldCreateResponseFromState = false;
|
||||||
responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} };
|
responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} };
|
||||||
singleUseId: string | null;
|
singleUseId: string | null;
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ export class SurveyState {
|
|||||||
* Update the display ID after a successful display creation
|
* Update the display ID after a successful display creation
|
||||||
* @param id - The display ID
|
* @param id - The display ID
|
||||||
*/
|
*/
|
||||||
updateDisplayId(id: string) {
|
updateDisplayId(id: string | null) {
|
||||||
this.displayId = id;
|
this.displayId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +80,14 @@ export class SurveyState {
|
|||||||
this.contactId = id;
|
this.contactId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enableBootstrapResponseCreate() {
|
||||||
|
this.shouldCreateResponseFromState = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
disableBootstrapResponseCreate() {
|
||||||
|
this.shouldCreateResponseFromState = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accumulate the responses
|
* Accumulate the responses
|
||||||
* @param responseUpdate - The new response data to add
|
* @param responseUpdate - The new response data to add
|
||||||
@@ -86,10 +95,14 @@ export class SurveyState {
|
|||||||
accumulateResponse(responseUpdate: TResponseUpdate) {
|
accumulateResponse(responseUpdate: TResponseUpdate) {
|
||||||
this.responseAcc = {
|
this.responseAcc = {
|
||||||
finished: responseUpdate.finished,
|
finished: responseUpdate.finished,
|
||||||
ttc: responseUpdate.ttc,
|
ttc: { ...this.responseAcc.ttc, ...responseUpdate.ttc },
|
||||||
data: { ...this.responseAcc.data, ...responseUpdate.data },
|
data: { ...this.responseAcc.data, ...responseUpdate.data },
|
||||||
variables: responseUpdate.variables,
|
variables: responseUpdate.variables ?? this.responseAcc.variables,
|
||||||
displayId: responseUpdate.displayId,
|
displayId: responseUpdate.displayId ?? this.responseAcc.displayId,
|
||||||
|
language: responseUpdate.language ?? this.responseAcc.language,
|
||||||
|
meta: responseUpdate.meta ?? this.responseAcc.meta,
|
||||||
|
hiddenFields: responseUpdate.hiddenFields ?? this.responseAcc.hiddenFields,
|
||||||
|
endingId: responseUpdate.endingId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +118,7 @@ export class SurveyState {
|
|||||||
*/
|
*/
|
||||||
clear() {
|
clear() {
|
||||||
this.responseId = null;
|
this.responseId = null;
|
||||||
|
this.shouldCreateResponseFromState = false;
|
||||||
this.responseAcc = { finished: false, data: {}, ttc: {}, variables: {} };
|
this.responseAcc = { finished: false, data: {}, ttc: {}, variables: {} };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
|
import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
|
||||||
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
import { type ApiErrorResponse } from "@formbricks/types/errors";
|
||||||
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
|
||||||
@@ -11,8 +12,8 @@ import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/type
|
|||||||
import { type TShuffleOption } from "@formbricks/types/surveys/types";
|
import { type TShuffleOption } from "@formbricks/types/surveys/types";
|
||||||
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
|
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
|
||||||
|
|
||||||
export const cn = (...classes: string[]) => {
|
export const cn = (...classes: (string | undefined)[]) => {
|
||||||
return classes.filter(Boolean).join(" ");
|
return twMerge(classes.filter(Boolean).join(" "));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSecureRandom = (): number => {
|
export const getSecureRandom = (): number => {
|
||||||
|
|||||||
@@ -154,6 +154,17 @@ const checkRequiredField = (
|
|||||||
return createRequiredError(t);
|
return createRequiredError(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For multi-select: if "other" is selected (sentinel ""), require the other text to be non-empty
|
||||||
|
if (element.type === TSurveyElementTypeEnum.MultipleChoiceMulti && Array.isArray(value)) {
|
||||||
|
const sentinelIndex = value.indexOf("");
|
||||||
|
if (sentinelIndex !== -1) {
|
||||||
|
const otherText = value[sentinelIndex + 1];
|
||||||
|
if (!otherText || (typeof otherText === "string" && otherText.trim() === "")) {
|
||||||
|
return createRequiredError(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
|
import { type TIntegrationAirtable, ZIntegrationAirtableConfig, ZIntegrationAirtableInput } from "./airtable";
|
||||||
import { ZIntegrationGoogleSheetsConfig, ZIntegrationGoogleSheetsInput } from "./google-sheet";
|
import {
|
||||||
import { ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion";
|
type TIntegrationGoogleSheets,
|
||||||
import { ZIntegrationSlackConfig, ZIntegrationSlackInput } from "./slack";
|
ZIntegrationGoogleSheetsConfig,
|
||||||
|
ZIntegrationGoogleSheetsInput,
|
||||||
|
} from "./google-sheet";
|
||||||
|
import { type TIntegrationNotion, ZIntegrationNotionConfig, ZIntegrationNotionInput } from "./notion";
|
||||||
|
import { type TIntegrationSlack, ZIntegrationSlackConfig, ZIntegrationSlackInput } from "./slack";
|
||||||
|
|
||||||
export const ZIntegrationType = z.enum(["googleSheets", "n8n", "airtable", "notion", "slack"]);
|
export const ZIntegrationType = z.enum(["googleSheets", "n8n", "airtable", "notion", "slack"]);
|
||||||
export type TIntegrationType = z.infer<typeof ZIntegrationType>;
|
export type TIntegrationType = z.infer<typeof ZIntegrationType>;
|
||||||
@@ -28,6 +32,16 @@ export const ZIntegration = ZIntegrationBase.extend({
|
|||||||
|
|
||||||
export type TIntegration = z.infer<typeof ZIntegration>;
|
export type TIntegration = z.infer<typeof ZIntegration>;
|
||||||
|
|
||||||
|
export type TIntegrationByType<T extends TIntegrationType> = T extends "airtable"
|
||||||
|
? TIntegrationAirtable
|
||||||
|
: T extends "googleSheets"
|
||||||
|
? TIntegrationGoogleSheets
|
||||||
|
: T extends "notion"
|
||||||
|
? TIntegrationNotion
|
||||||
|
: T extends "slack"
|
||||||
|
? TIntegrationSlack
|
||||||
|
: TIntegration;
|
||||||
|
|
||||||
export const ZIntegrationBaseSurveyData = z.object({
|
export const ZIntegrationBaseSurveyData = z.object({
|
||||||
createdAt: z.date(),
|
createdAt: z.date(),
|
||||||
elementIds: z.array(z.string()),
|
elementIds: z.array(z.string()),
|
||||||
|
|||||||
Generated
+3
@@ -990,6 +990,9 @@ importers:
|
|||||||
react-i18next:
|
react-i18next:
|
||||||
specifier: 16.5.8
|
specifier: 16.5.8
|
||||||
version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
version: 16.5.8(i18next@25.8.18(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: 3.5.0
|
||||||
|
version: 3.5.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@formbricks/config-typescript':
|
'@formbricks/config-typescript':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
|
|||||||
Reference in New Issue
Block a user