Skip to content
Merged
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ PASSWORD_RESET_DISABLED=1
# Organization Invite. Disable the ability for invited users to create an account.
# INVITE_DISABLED=1

# Also apply the signup email-domain check to invited users. "1" to enable.
# SIGNUP_DOMAIN_CHECK_ON_INVITES=1

##########
# Other #
##########
Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
"use client";

import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { waitForBillingPlanAction } from "@/modules/ee/billing/actions";
import { Button } from "@/modules/ui/components/button";
import { Confetti } from "@/modules/ui/components/confetti";

const BILLING_CONFIRMATION_ORGANIZATION_ID_KEY = "billingConfirmationOrganizationId";
const SYNCABLE_PLANS = ["pro", "scale"] as const;
type TSyncablePlan = (typeof SYNCABLE_PLANS)[number];

const isSyncablePlan = (plan: string | null): plan is TSyncablePlan =>
plan !== null && (SYNCABLE_PLANS as readonly string[]).includes(plan);

export const ConfirmationPage = () => {
const { t } = useTranslation();
const [showConfetti, setShowConfetti] = useState(false);
const [resolvedOrganizationId, setResolvedOrganizationId] = useState<string | null>(null);
// Block the back link until Stripe is synced, so returning to billing renders the new plan.
const [isSyncing, setIsSyncing] = useState(false);

useEffect(() => {
setShowConfetti(true);
Expand All @@ -22,16 +29,41 @@ export const ConfirmationPage = () => {

// Prefer the organizationId Stripe appends to the return URL so the back link survives a
// fresh tab / cleared session storage; fall back to session storage otherwise.
const urlOrganizationId = new URLSearchParams(globalThis.window.location.search).get("organizationId");
const params = new URLSearchParams(globalThis.window.location.search);
const urlOrganizationId = params.get("organizationId");
const storedOrganizationId = globalThis.window.sessionStorage.getItem(
BILLING_CONFIRMATION_ORGANIZATION_ID_KEY
);
const organizationId = urlOrganizationId || storedOrganizationId;
if (organizationId) {
setResolvedOrganizationId(organizationId);
}

// The read-through sync only refreshes a >5min-stale snapshot, so the fresh post-checkout snapshot
// still shows the old plan. Force a Stripe sync here (poll until the purchased plan lands) so the
// billing page renders the new plan on return.
const plan = params.get("plan");
if (!organizationId || !isSyncablePlan(plan)) {
return;
}

let cancelled = false;
setIsSyncing(true);
void waitForBillingPlanAction({ organizationId, targetPlan: plan }).finally(() => {
if (!cancelled) {
setIsSyncing(false);
}
});

return () => {
cancelled = true;
};
}, []);

const billingHref = resolvedOrganizationId
? `/organizations/${resolvedOrganizationId}/settings/billing`
: "/";

return (
<div className="h-full w-full">
{showConfetti && <Confetti />}
Expand All @@ -44,12 +76,18 @@ export const ConfirmationPage = () => {
{t("billing_confirmation.thanks_for_upgrading")}
</p>
</div>
<Button asChild className="w-full justify-center">
<Link
href={resolvedOrganizationId ? `/organizations/${resolvedOrganizationId}/settings/billing` : "/"}>
{isSyncing ? (
<Button loading disabled className="w-full justify-center">
{t("billing_confirmation.back_to_billing_overview")}
</Link>
</Button>
</Button>
) : (
<Button asChild className="w-full justify-center">
{/* Full-document navigation (not next/link): a client transition would serve the billing
page's prefetched RSC from before checkout, still showing the old plan. A full load
re-renders it fresh with the upgraded plan. */}
<a href={billingHref}>{t("billing_confirmation.back_to_billing_overview")}</a>
</Button>
)}
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const getBaseSurvey = () =>
({
id: surveyId,
workspaceId,
status: "inProgress",
blocks: [],
questions: [],
}) as const;
Expand Down Expand Up @@ -317,6 +318,23 @@ describe("putResponseHandler", () => {
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});

test("rejects updates when the survey is closed (not accepting submissions)", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
status: "completed",
});

const result = await putResponseHandler(createHandlerParams());

expect(result.response.status).toBe(403);
await expect(result.response.json()).resolves.toEqual({
code: "forbidden",
message: "Survey is not accepting submissions",
details: { surveyId },
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});

test("rejects invalid file upload updates", async () => {
mocks.validateClientFileUploads.mockReturnValue(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,16 @@ const validateUpdateRequest = (
responseUpdateInput: TResponseUpdateInput,
workspaceId: string
): TRouteResult | undefined => {
// Mirror the POST endpoint: a closed survey accepts no submissions, including finalizing a partial
// response created while it was open (ENG-1654).
if (survey.status !== "inProgress") {
return {
response: responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
}),
};
}

if (existingResponse.finished) {
return {
response: responses.badRequestResponse(
Expand Down
23 changes: 23 additions & 0 deletions apps/web/app/api/v3/surveys/patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,29 @@ describe("patchV3Survey", () => {
);
});

test("rejects setting a non-draft status on an app survey with no triggers", async () => {
await expect(
executeV3SurveyPatch({
currentSurvey: { ...currentSurvey, type: "app", triggers: [] } as TSurvey,
document: {
name: currentSurvey.name,
status: "inProgress",
metadata: currentSurvey.metadata,
defaultLanguage: "en-US",
languages: [{ code: "en-US", enabled: true }],
welcomeCard: currentSurvey.welcomeCard,
blocks: currentSurvey.blocks,
endings: currentSurvey.endings,
hiddenFields: currentSurvey.hiddenFields,
variables: currentSurvey.variables,
} as unknown as Parameters<typeof executeV3SurveyPatch>[0]["document"],
languageRequests: [{ code: "en-US", default: true, enabled: true }],
requestId: "req_1",
})
).rejects.toBeInstanceOf(V3SurveyReferenceValidationError);
expect(prisma.survey.update).not.toHaveBeenCalled();
});

test("reconciles due survey schedules without refetching when no schedule transition persisted", async () => {
vi.mocked(isSurveySchedulingDue).mockReturnValue(true);
vi.mocked(reconcileDueSurveySchedules).mockResolvedValue({
Expand Down
17 changes: 16 additions & 1 deletion apps/web/app/api/v3/surveys/patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { getActionClasses } from "@/lib/actionClass/service";
import { selectSurvey } from "@/lib/survey/service";
import { stripIsDraftFromBlocks, transformPrismaSurvey } from "@/lib/survey/utils";
import {
APP_SURVEY_TRIGGER_REQUIRED_MESSAGE,
isAppSurveyMissingTriggersToPublish,
stripIsDraftFromBlocks,
transformPrismaSurvey,
} from "@/lib/survey/utils";
import { handleTriggerUpdates } from "@/modules/survey/lib/trigger-updates";
import {
isSurveySchedulingDue,
Expand Down Expand Up @@ -174,6 +179,16 @@ export async function executeV3SurveyPatch(params: {
throw new V3SurveyReferenceValidationError(mediaInvalidParams);
}

// An app survey can never be shown without a trigger, so reject setting a non-draft status with no
// effective triggers. Triggers only change when the patch carries a `distribution` (top-level
// replacement); otherwise the survey keeps its current triggers.
const effectiveTriggers = document.distribution ? document.distribution.triggers : currentSurvey.triggers;
if (isAppSurveyMissingTriggersToPublish(currentSurvey.type, document.status, effectiveTriggers)) {
throw new V3SurveyReferenceValidationError([
{ name: "triggers", reason: APP_SURVEY_TRIGGER_REQUIRED_MESSAGE },
]);
}

const languages = await ensureV3WorkspaceLanguages(currentSurvey.workspaceId, languageRequests, requestId);
const normalizedScheduling = normalizeSurveyScheduling({
currentStatus: currentSurvey.status,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/i18n.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1595,6 +1595,7 @@ checksums:
workspace/analysis/charts/dimensions_toggle_description: 31eb28f3c83c04bbe37799758ca9f595
workspace/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
workspace/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
workspace/analysis/charts/edit_chart_title_named: 216b4226fb611b3694bc222026722845
workspace/analysis/charts/emotion_value_anger: 4fd64fc804c247fc3fcbc7dc147b62fe
workspace/analysis/charts/emotion_value_disgust: e5860fbfc609c8c8686db8a18cadf932
workspace/analysis/charts/emotion_value_fear: b7621ac47c2543dff100f55f948df8cf
Expand Down Expand Up @@ -1732,6 +1733,8 @@ checksums:
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
workspace/analysis/dashboards/chart_duplicate_failed: 90d7166c85188b52f821c9d9f53ff8c4
workspace/analysis/dashboards/chart_duplicated: 52765f173bd6fd5382731f8e06e436e0
workspace/analysis/dashboards/chart_remove_failed: 198f5c3bdd522b40b80cb7479d3051a1
workspace/analysis/dashboards/chart_removed: 1ce20b8ee0b56bcd7d6fea2b5c1ae9fd
workspace/analysis/dashboards/chart_restore_failed: 890c74046ed55df6f90c28cf905b7a30
Expand Down
9 changes: 8 additions & 1 deletion apps/web/lib/survey/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ describe("Tests for createSurvey", () => {
name: "Test Survey",
type: "app" as const,
createdBy: mockUserId,
status: "inProgress" as const,
status: "draft" as const,
welcomeCard: {
enabled: true,
headline: { default: "Welcome" },
Expand Down Expand Up @@ -663,6 +663,13 @@ describe("Tests for createSurvey", () => {
expect(subscribeOrganizationMembersToSurveyResponses).toHaveBeenCalled();
});

test("throws InvalidInputError when creating a non-draft app survey with no triggers", async () => {
await expect(
createSurvey(mockWorkspaceId, { ...mockCreateSurveyInput, type: "app", status: "inProgress" })
).rejects.toThrow(InvalidInputError);
expect(prisma.survey.create).not.toHaveBeenCalled();
});

test("creates a private segment for app surveys", async () => {
vi.mocked(getOrganizationByWorkspaceId).mockResolvedValueOnce(mockOrganizationOutput);
prisma.survey.create.mockResolvedValueOnce({
Expand Down
20 changes: 20 additions & 0 deletions apps/web/lib/survey/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import { getActionClasses } from "../actionClass/service";
import { ITEMS_PER_PAGE } from "../constants";
import { validateInputs } from "../utils/validate";
import {
APP_SURVEY_TRIGGER_REQUIRED_MESSAGE,
checkForInvalidImagesInQuestions,
checkForInvalidMediaInBlocks,
isAppSurveyMissingTriggersToPublish,
stripIsDraftFromBlocks,
transformPrismaSurvey,
validateMediaAndPrepareBlocks,
Expand Down Expand Up @@ -293,6 +295,12 @@ export const updateSurveyInternal = async (

if (!skipValidation) {
checkForInvalidImagesInQuestions(questions);

// An app survey can never be shown without a trigger, so block publishing (non-draft status)
// one with zero triggers. The editor enforces this client-side only; mirror it server-side.
if (isAppSurveyMissingTriggersToPublish(type, updatedSurvey.status, triggers)) {
throw new InvalidInputError(APP_SURVEY_TRIGGER_REQUIRED_MESSAGE);
}
}

// Add blocks media validation. The validation error is already an InvalidInputError, so the
Expand Down Expand Up @@ -665,6 +673,18 @@ export const createSurvey = async (
const { createdBy, languages, segment, followUps, styling, ...restSurveyBody } = parsedSurveyBody;
assertSurveyLanguagesBelongToWorkspace(parsedWorkspaceId, languages);
await assertSurveySegmentBelongsToWorkspace(parsedWorkspaceId, segment);

// An app survey can never be shown without a trigger, so block creating one directly in a
// non-draft status with zero triggers (mirrors the editor's publish guard).
if (
isAppSurveyMissingTriggersToPublish(
restSurveyBody.type ?? "link",
restSurveyBody.status ?? "draft",
restSurveyBody.triggers
)
) {
throw new InvalidInputError(APP_SURVEY_TRIGGER_REQUIRED_MESSAGE);
}
const normalizedCloseOn = restSurveyBody.closeOn instanceof Date ? restSurveyBody.closeOn : null;
const normalizedPublishOn = restSurveyBody.publishOn instanceof Date ? restSurveyBody.publishOn : null;
const surveyLanguagesCreateData: Prisma.SurveyLanguageCreateNestedManyWithoutSurveyInput | undefined =
Expand Down
18 changes: 18 additions & 0 deletions apps/web/lib/survey/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,28 @@ import {
anySurveyHasFilters,
checkForInvalidImagesInQuestions,
checkForInvalidMediaInBlocks,
isAppSurveyMissingTriggersToPublish,
transformPrismaSurvey,
validateMediaAndPrepareBlocks,
} from "./utils";

describe("isAppSurveyMissingTriggersToPublish", () => {
test("flags a non-draft app survey with no triggers", () => {
expect(isAppSurveyMissingTriggersToPublish("app", "inProgress", [])).toBe(true);
expect(isAppSurveyMissingTriggersToPublish("app", "paused", null)).toBe(true);
expect(isAppSurveyMissingTriggersToPublish("app", "completed", undefined)).toBe(true);
});

test("allows a non-draft app survey that has at least one trigger", () => {
expect(isAppSurveyMissingTriggersToPublish("app", "inProgress", [{ actionClassId: "a" }])).toBe(false);
});

test("ignores draft app surveys and non-app surveys", () => {
expect(isAppSurveyMissingTriggersToPublish("app", "draft", [])).toBe(false);
expect(isAppSurveyMissingTriggersToPublish("link", "inProgress", [])).toBe(false);
});
});

describe("transformPrismaSurvey", () => {
test("transforms prisma survey without segment", () => {
const surveyPrisma = {
Expand Down
22 changes: 21 additions & 1 deletion apps/web/lib/survey/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,30 @@ import {
TSurveyElementTypeEnum,
TSurveyPictureChoice,
} from "@formbricks/types/surveys/elements";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import {
TSurvey,
TSurveyQuestion,
TSurveyQuestionTypeEnum,
TSurveyStatus,
TSurveyType,
} from "@formbricks/types/surveys/types";
import { isValidVideoUrl } from "@/lib/utils/video-upload";
import { isValidImageFile } from "@/modules/storage/utils";

/**
* A live (non-draft) app survey must have at least one trigger, otherwise it can never be displayed
* ("live but dormant"). The survey editor only enforces this client-side, so every server write path
* (create + update, including the v3 API) applies this check to keep the API in parity with the UI.
*/
export const APP_SURVEY_TRIGGER_REQUIRED_MESSAGE =
"An app survey must have at least one trigger to be set to a non-draft status.";

export const isAppSurveyMissingTriggersToPublish = (
type: TSurveyType,
status: TSurveyStatus,
triggers: readonly unknown[] | null | undefined
): boolean => type === "app" && status !== "draft" && (triggers?.length ?? 0) === 0;

/**
* Actionable hint appended to invalid-image messages. Names the supported formats and explains why
* extension-less external/CDN URLs (e.g. Unsplash, signed URLs, `?format=auto`) are rejected, since
Expand Down
3 changes: 3 additions & 0 deletions apps/web/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -1658,6 +1658,7 @@
"dimensions_toggle_description": "Gruppiere Daten nach Stimmung, Fragetyp und anderen Dimensionen.",
"edit_chart_description": "Sieh dir deine Diagrammkonfiguration an und bearbeite sie.",
"edit_chart_title": "Diagramm bearbeiten",
"edit_chart_title_named": "„{name}“ bearbeiten",
"emotion_value_anger": "Wut",
"emotion_value_disgust": "Ekel",
"emotion_value_fear": "Angst",
Expand Down Expand Up @@ -1797,6 +1798,8 @@
},
"dashboards": {
"add_count_charts": "{count} Diagramm(e) hinzufügen",
"chart_duplicate_failed": "Diagramm konnte nicht dupliziert werden",
"chart_duplicated": "Diagramm wurde dupliziert und zum Dashboard hinzugefügt",
"chart_remove_failed": "Diagramm konnte nicht entfernt werden",
"chart_removed": "Diagramm vom Dashboard entfernt",
"chart_restore_failed": "Diagramm konnte nicht wiederhergestellt werden",
Expand Down
3 changes: 3 additions & 0 deletions apps/web/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1658,6 +1658,7 @@
"dimensions_toggle_description": "Group data by sentiment, question type, and other dimensions.",
"edit_chart_description": "View and edit your chart configuration.",
"edit_chart_title": "Edit Chart",
"edit_chart_title_named": "Edit \"{name}\"",
"emotion_value_anger": "Anger",
"emotion_value_disgust": "Disgust",
"emotion_value_fear": "Fear",
Expand Down Expand Up @@ -1797,6 +1798,8 @@
},
"dashboards": {
"add_count_charts": "Add {count} chart(s)",
"chart_duplicate_failed": "Failed to duplicate chart",
"chart_duplicated": "Chart duplicated and added to dashboard",
"chart_remove_failed": "Failed to remove chart",
"chart_removed": "Chart removed from dashboard",
"chart_restore_failed": "Failed to restore chart",
Expand Down
Loading
Loading