Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,12 @@ LOG_LEVEL=info
# Number of concurrent jobs each BullMQ worker can process.
# BULLMQ_WORKER_CONCURRENCY=1

# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
# also needs to render the selected execution time and timezone.
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
# Survey publish/close scheduling execution time. These are server-side runtime env vars, so
# self-hosted Docker deployments can change them without rebuilding the image. The editor UI
# receives the configured values from the server to render the selected execution time and timezone.
# SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
# SURVEY_SCHEDULING_LOCAL_HOUR=0
# SURVEY_SCHEDULING_LOCAL_MINUTE=0

##############
# DATABASE #
Expand Down
30 changes: 15 additions & 15 deletions apps/web/lib/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,51 +301,51 @@ describe("env", () => {

test("uses the default survey scheduling configuration when env vars are not set", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: undefined,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: undefined,
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: undefined,
SURVEY_SCHEDULING_LOCAL_HOUR: undefined,
SURVEY_SCHEDULING_LOCAL_MINUTE: undefined,
SURVEY_SCHEDULING_TIME_ZONE: undefined,
});

const { env } = await import("./env");

expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE).toBe("Europe/Berlin");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR).toBe(0);
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE).toBe(0);
expect(env.SURVEY_SCHEDULING_TIME_ZONE).toBe("Europe/Berlin");
expect(env.SURVEY_SCHEDULING_LOCAL_HOUR).toBe(0);
expect(env.SURVEY_SCHEDULING_LOCAL_MINUTE).toBe(0);
});

test("uses the configured survey scheduling configuration", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: "18",
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: "45",
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: "America/New_York",
SURVEY_SCHEDULING_LOCAL_HOUR: "18",
SURVEY_SCHEDULING_LOCAL_MINUTE: "45",
SURVEY_SCHEDULING_TIME_ZONE: "America/New_York",
});

const { env } = await import("./env");

expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE).toBe("America/New_York");
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR).toBe(18);
expect(env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE).toBe(45);
expect(env.SURVEY_SCHEDULING_TIME_ZONE).toBe("America/New_York");
expect(env.SURVEY_SCHEDULING_LOCAL_HOUR).toBe(18);
expect(env.SURVEY_SCHEDULING_LOCAL_MINUTE).toBe(45);
});

test("fails to load when the survey scheduling timezone is invalid", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: "Mars/OlympusMons",
SURVEY_SCHEDULING_TIME_ZONE: "Mars/OlympusMons",
});

await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});

test("fails to load when the survey scheduling hour is out of range", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: "24",
SURVEY_SCHEDULING_LOCAL_HOUR: "24",
});

await expect(import("./env")).rejects.toThrow("Invalid environment variables");
});

test("fails to load when the survey scheduling minute is out of range", async () => {
setTestEnv({
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: "60",
SURVEY_SCHEDULING_LOCAL_MINUTE: "60",
});

await expect(import("./env")).rejects.toThrow("Invalid environment variables");
Expand Down
17 changes: 8 additions & 9 deletions apps/web/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ const isValidIanaTimeZone = (value: string): boolean => {
};

const ZSurveySchedulingTimeZone = z.string().trim().min(1).refine(isValidIanaTimeZone, {
message: "NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE must be a valid IANA time zone",
message: "SURVEY_SCHEDULING_TIME_ZONE must be a valid IANA time zone",
});

const ZSurveySchedulingLocalHour = z.coerce.number().int().min(0).max(23);
Expand Down Expand Up @@ -374,12 +374,11 @@ const parsedEnv = createEnv({
.transform((val) => Number.parseInt(val, 10))
.optional(),
SENTRY_ENVIRONMENT: z.string().optional(),
SURVEY_SCHEDULING_TIME_ZONE: ZSurveySchedulingTimeZone.optional().default("Europe/Berlin"),
SURVEY_SCHEDULING_LOCAL_HOUR: ZSurveySchedulingLocalHour.optional().default(0),
SURVEY_SCHEDULING_LOCAL_MINUTE: ZSurveySchedulingLocalMinute.optional().default(0),
},
client: {
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: ZSurveySchedulingTimeZone.optional().default("Europe/Berlin"),
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: ZSurveySchedulingLocalHour.optional().default(0),
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: ZSurveySchedulingLocalMinute.optional().default(0),
},
client: {},

/*
* Due to how Next.js bundles environment variables on Edge and Client,
Expand Down Expand Up @@ -464,9 +463,9 @@ const parsedEnv = createEnv({
MAIL_FROM_NAME: process.env.MAIL_FROM_NAME,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR,
NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE,
NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE: process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE,
SURVEY_SCHEDULING_LOCAL_HOUR: process.env.SURVEY_SCHEDULING_LOCAL_HOUR,
SURVEY_SCHEDULING_LOCAL_MINUTE: process.env.SURVEY_SCHEDULING_LOCAL_MINUTE,
SURVEY_SCHEDULING_TIME_ZONE: process.env.SURVEY_SCHEDULING_TIME_ZONE,
SENTRY_DSN: process.env.SENTRY_DSN,
NOTION_OAUTH_CLIENT_ID: process.env.NOTION_OAUTH_CLIENT_ID,
NOTION_OAUTH_CLIENT_SECRET: process.env.NOTION_OAUTH_CLIENT_SECRET,
Expand Down
31 changes: 19 additions & 12 deletions apps/web/modules/survey/editor/components/response-options-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,18 @@

import * as Collapsible from "@radix-ui/react-collapsible";
import { CheckIcon } from "lucide-react";
import { KeyboardEventHandler, useEffect, useState } from "react";
import { KeyboardEventHandler, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { cn } from "@/lib/cn";
import {
SURVEY_SCHEDULING_TIME_LABEL,
SURVEY_SCHEDULING_TIME_ZONE_LABEL,
} from "@/modules/survey/scheduling/lib/constants";
type TSurveySchedulingConfig,
getSurveySchedulingTimeLabel,
} from "@/modules/survey/scheduling/lib/config";
import {
getMinimumSurveySchedulingCalendarDate,
toCalendarDate,
createSurveySchedulingDateUtils,
toDateOnlySelection,
} from "@/modules/survey/scheduling/lib/date-utils";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
Expand All @@ -29,6 +28,7 @@ interface ResponseOptionsCardProps {
setLocalSurvey: (survey: TSurvey | ((prev: TSurvey) => TSurvey)) => void;
responseCount: number;
isSpamProtectionAllowed: boolean;
surveySchedulingConfig: TSurveySchedulingConfig;
locale: TUserLocale;
}

Expand All @@ -37,9 +37,16 @@ export const ResponseOptionsCard = ({
setLocalSurvey,
responseCount,
isSpamProtectionAllowed,
surveySchedulingConfig,
locale,
}: ResponseOptionsCardProps) => {
}: Readonly<ResponseOptionsCardProps>) => {
const { t } = useTranslation();
const { toCalendarDate, getMinimumSurveySchedulingCalendarDate } = useMemo(
() => createSurveySchedulingDateUtils(surveySchedulingConfig),
[surveySchedulingConfig]
);
const surveySchedulingTimeLabel = getSurveySchedulingTimeLabel(surveySchedulingConfig);
const surveySchedulingTimeZoneLabel = surveySchedulingConfig.timeZone;
const [open, setOpen] = useState(localSurvey.type === "link");
const autoComplete = localSurvey.autoComplete !== null;
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
Expand Down Expand Up @@ -193,7 +200,7 @@ export const ResponseOptionsCard = ({
closeOn: null,
};
});
}, [closeOn, publishOn, setLocalSurvey]);
}, [closeOn, publishOn, setLocalSurvey, toCalendarDate]);

const togglePublishOnDate = () => {
if (isPublishOnDateEnabled) {
Expand Down Expand Up @@ -331,8 +338,8 @@ export const ResponseOptionsCard = ({
onToggle={togglePublishOnDate}
title={t("workspace.surveys.edit.publish_survey_on_date")}
description={t("workspace.surveys.edit.survey_will_be_published_at_midnight_cet", {
time: SURVEY_SCHEDULING_TIME_LABEL,
timeZone: SURVEY_SCHEDULING_TIME_ZONE_LABEL,
time: surveySchedulingTimeLabel,
timeZone: surveySchedulingTimeZoneLabel,
})}
childBorder={true}>
<div className="p-4">
Expand Down Expand Up @@ -371,8 +378,8 @@ export const ResponseOptionsCard = ({
onToggle={toggleCloseOnDate}
title={t("workspace.surveys.edit.close_survey_on_date")}
description={t("workspace.surveys.edit.survey_will_be_closed_at_midnight_cet", {
time: SURVEY_SCHEDULING_TIME_LABEL,
timeZone: SURVEY_SCHEDULING_TIME_ZONE_LABEL,
time: surveySchedulingTimeLabel,
timeZone: surveySchedulingTimeZoneLabel,
})}
childBorder={true}>
<div className="p-4">
Expand Down
4 changes: 4 additions & 0 deletions apps/web/modules/survey/editor/components/settings-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ResponseOptionsCard } from "@/modules/survey/editor/components/response
import { SurveyPlacementCard } from "@/modules/survey/editor/components/survey-placement-card";
import { TargetingLockedCard } from "@/modules/survey/editor/components/targeting-locked-card";
import { WhenToSendCard } from "@/modules/survey/editor/components/when-to-send-card";
import { type TSurveySchedulingConfig } from "@/modules/survey/scheduling/lib/config";

interface SettingsViewProps {
localSurvey: TSurvey;
Expand All @@ -29,6 +30,7 @@ interface SettingsViewProps {
isFormbricksCloud: boolean;
isQuotasAllowed: boolean;
quotas: TSurveyQuota[];
surveySchedulingConfig: TSurveySchedulingConfig;
locale: TUserLocale;
appSetupCompleted: boolean;
enterpriseLicenseRequestFormUrl: string;
Expand All @@ -48,6 +50,7 @@ export const SettingsView = ({
workspacePermission,
isFormbricksCloud,
quotas,
surveySchedulingConfig,
locale,
appSetupCompleted,
enterpriseLicenseRequestFormUrl,
Expand Down Expand Up @@ -108,6 +111,7 @@ export const SettingsView = ({
setLocalSurvey={setLocalSurvey}
responseCount={responseCount}
isSpamProtectionAllowed={isSpamProtectionAllowed}
surveySchedulingConfig={surveySchedulingConfig}
locale={locale}
/>

Expand Down
4 changes: 4 additions & 0 deletions apps/web/modules/survey/editor/components/survey-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { SurveyMenuBar } from "@/modules/survey/editor/components/survey-menu-ba
import { TFollowUpEmailToUser } from "@/modules/survey/editor/types/survey-follow-up";
import { FollowUpsView } from "@/modules/survey/follow-ups/components/follow-ups-view";
import { LanguageView } from "@/modules/survey/multi-language-surveys/components/language-view";
import { type TSurveySchedulingConfig } from "@/modules/survey/scheduling/lib/config";
import { PreviewSurvey } from "@/modules/ui/components/preview-survey";
import { getWorkspaceLanguagesAction, refetchWorkspaceAction } from "../actions";

Expand All @@ -39,6 +40,7 @@ interface SurveyEditorProps {
isUnsplashConfigured: boolean;
isQuotasAllowed: boolean;
isCxMode: boolean;
surveySchedulingConfig: TSurveySchedulingConfig;
locale: TUserLocale;
workspacePermission: TTeamPermission | null;
mailFrom: string;
Expand Down Expand Up @@ -69,6 +71,7 @@ export const SurveyEditor = ({
isUnsplashConfigured,
isQuotasAllowed,
isCxMode = false,
surveySchedulingConfig,
locale,
workspacePermission,
mailFrom,
Expand Down Expand Up @@ -266,6 +269,7 @@ export const SurveyEditor = ({
isFormbricksCloud={isFormbricksCloud}
isQuotasAllowed={isQuotasAllowed}
quotas={quotas}
surveySchedulingConfig={surveySchedulingConfig}
locale={locale}
appSetupCompleted={localWorkspace.appSetupCompleted}
enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/modules/survey/editor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { getExternalUrlsPermission } from "@/modules/survey/lib/permission";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling, getSurvey } from "@/modules/survey/lib/survey";
import { getWorkspaceWithTeamIds } from "@/modules/survey/lib/workspace";
import { SURVEY_SCHEDULING_CONFIG } from "@/modules/survey/scheduling/lib/constants";
import { ErrorComponent } from "@/modules/ui/components/error-component";
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
import { SurveyEditor } from "./components/survey-editor";
Expand Down Expand Up @@ -128,6 +129,7 @@ export const SurveyEditorPage = async (props: {
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
isUnsplashConfigured={!!UNSPLASH_ACCESS_KEY}
isCxMode={isCxMode}
surveySchedulingConfig={SURVEY_SCHEDULING_CONFIG}
locale={locale ?? DEFAULT_LOCALE}
mailFrom={MAIL_FROM ?? "hola@formbricks.com"}
isSurveyFollowUpsAllowed={isSurveyFollowUpsAllowed}
Expand Down
10 changes: 10 additions & 0 deletions apps/web/modules/survey/scheduling/lib/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface TSurveySchedulingConfig {
timeZone: string;
localHour: number;
localMinute: number;
}

const padSchedulingTimePart = (value: number): string => value.toString().padStart(2, "0");

export const getSurveySchedulingTimeLabel = (config: TSurveySchedulingConfig): string =>
`${padSchedulingTimePart(config.localHour)}:${padSchedulingTimePart(config.localMinute)}`;
55 changes: 55 additions & 0 deletions apps/web/modules/survey/scheduling/lib/constants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";

const ORIGINAL_ENV = process.env;

const setSchedulingEnv = (overrides: Record<string, string | undefined> = {}) => {
process.env = {
...ORIGINAL_ENV,
SURVEY_SCHEDULING_TIME_ZONE: undefined,
SURVEY_SCHEDULING_LOCAL_HOUR: undefined,
SURVEY_SCHEDULING_LOCAL_MINUTE: undefined,
...overrides,
};
};

describe("survey scheduling constants", () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
process.env = ORIGINAL_ENV;
});

test("defaults to Europe/Berlin at midnight when env vars are unset", async () => {
setSchedulingEnv();

const constants = await import("./constants");

expect(constants.SURVEY_SCHEDULING_CONFIG).toEqual({
timeZone: "Europe/Berlin",
localHour: 0,
localMinute: 0,
});
expect(constants.SURVEY_SCHEDULING_TIME_LABEL).toBe("00:00");
expect(constants.SURVEY_SCHEDULING_DAILY_CRON_PATTERN).toBe("0 0 * * *");
});

test("reads a valid runtime configuration from server-side env vars", async () => {
setSchedulingEnv({
SURVEY_SCHEDULING_TIME_ZONE: "America/New_York",
SURVEY_SCHEDULING_LOCAL_HOUR: "18",
SURVEY_SCHEDULING_LOCAL_MINUTE: "45",
});

const constants = await import("./constants");

expect(constants.SURVEY_SCHEDULING_CONFIG).toEqual({
timeZone: "America/New_York",
localHour: 18,
localMinute: 45,
});
expect(constants.SURVEY_SCHEDULING_TIME_LABEL).toBe("18:45");
expect(constants.SURVEY_SCHEDULING_DAILY_CRON_PATTERN).toBe("45 18 * * *");
});
});
Loading
Loading