diff --git a/.env.example b/.env.example index 5b457648f6d9..568f2d98d0c5 100644 --- a/.env.example +++ b/.env.example @@ -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 # diff --git a/apps/web/lib/env.test.ts b/apps/web/lib/env.test.ts index f66eda209f0a..e007377ce88b 100644 --- a/apps/web/lib/env.test.ts +++ b/apps/web/lib/env.test.ts @@ -301,35 +301,35 @@ 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"); @@ -337,7 +337,7 @@ describe("env", () => { 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"); @@ -345,7 +345,7 @@ describe("env", () => { 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"); diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index 2805f5256b78..c56b10cca36a 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -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); @@ -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, @@ -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, diff --git a/apps/web/modules/survey/editor/components/response-options-card.tsx b/apps/web/modules/survey/editor/components/response-options-card.tsx index 42f47628434b..b30837efc128 100644 --- a/apps/web/modules/survey/editor/components/response-options-card.tsx +++ b/apps/web/modules/survey/editor/components/response-options-card.tsx @@ -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"; @@ -29,6 +28,7 @@ interface ResponseOptionsCardProps { setLocalSurvey: (survey: TSurvey | ((prev: TSurvey) => TSurvey)) => void; responseCount: number; isSpamProtectionAllowed: boolean; + surveySchedulingConfig: TSurveySchedulingConfig; locale: TUserLocale; } @@ -37,9 +37,16 @@ export const ResponseOptionsCard = ({ setLocalSurvey, responseCount, isSpamProtectionAllowed, + surveySchedulingConfig, locale, -}: ResponseOptionsCardProps) => { +}: Readonly) => { 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); @@ -193,7 +200,7 @@ export const ResponseOptionsCard = ({ closeOn: null, }; }); - }, [closeOn, publishOn, setLocalSurvey]); + }, [closeOn, publishOn, setLocalSurvey, toCalendarDate]); const togglePublishOnDate = () => { if (isPublishOnDateEnabled) { @@ -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}>
@@ -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}>
diff --git a/apps/web/modules/survey/editor/components/settings-view.tsx b/apps/web/modules/survey/editor/components/settings-view.tsx index b486c5b47564..084136144ebc 100644 --- a/apps/web/modules/survey/editor/components/settings-view.tsx +++ b/apps/web/modules/survey/editor/components/settings-view.tsx @@ -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; @@ -29,6 +30,7 @@ interface SettingsViewProps { isFormbricksCloud: boolean; isQuotasAllowed: boolean; quotas: TSurveyQuota[]; + surveySchedulingConfig: TSurveySchedulingConfig; locale: TUserLocale; appSetupCompleted: boolean; enterpriseLicenseRequestFormUrl: string; @@ -48,6 +50,7 @@ export const SettingsView = ({ workspacePermission, isFormbricksCloud, quotas, + surveySchedulingConfig, locale, appSetupCompleted, enterpriseLicenseRequestFormUrl, @@ -108,6 +111,7 @@ export const SettingsView = ({ setLocalSurvey={setLocalSurvey} responseCount={responseCount} isSpamProtectionAllowed={isSpamProtectionAllowed} + surveySchedulingConfig={surveySchedulingConfig} locale={locale} /> diff --git a/apps/web/modules/survey/editor/components/survey-editor.tsx b/apps/web/modules/survey/editor/components/survey-editor.tsx index 3a13006c634c..6b49bc5ee8e2 100644 --- a/apps/web/modules/survey/editor/components/survey-editor.tsx +++ b/apps/web/modules/survey/editor/components/survey-editor.tsx @@ -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"; @@ -39,6 +40,7 @@ interface SurveyEditorProps { isUnsplashConfigured: boolean; isQuotasAllowed: boolean; isCxMode: boolean; + surveySchedulingConfig: TSurveySchedulingConfig; locale: TUserLocale; workspacePermission: TTeamPermission | null; mailFrom: string; @@ -69,6 +71,7 @@ export const SurveyEditor = ({ isUnsplashConfigured, isQuotasAllowed, isCxMode = false, + surveySchedulingConfig, locale, workspacePermission, mailFrom, @@ -266,6 +269,7 @@ export const SurveyEditor = ({ isFormbricksCloud={isFormbricksCloud} isQuotasAllowed={isQuotasAllowed} quotas={quotas} + surveySchedulingConfig={surveySchedulingConfig} locale={locale} appSetupCompleted={localWorkspace.appSetupCompleted} enterpriseLicenseRequestFormUrl={enterpriseLicenseRequestFormUrl} diff --git a/apps/web/modules/survey/editor/page.tsx b/apps/web/modules/survey/editor/page.tsx index 9495edac8343..c02fa708252f 100644 --- a/apps/web/modules/survey/editor/page.tsx +++ b/apps/web/modules/survey/editor/page.tsx @@ -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"; @@ -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} diff --git a/apps/web/modules/survey/scheduling/lib/config.ts b/apps/web/modules/survey/scheduling/lib/config.ts new file mode 100644 index 000000000000..a0cf37ff7aed --- /dev/null +++ b/apps/web/modules/survey/scheduling/lib/config.ts @@ -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)}`; diff --git a/apps/web/modules/survey/scheduling/lib/constants.test.ts b/apps/web/modules/survey/scheduling/lib/constants.test.ts new file mode 100644 index 000000000000..ca0c323aadaf --- /dev/null +++ b/apps/web/modules/survey/scheduling/lib/constants.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +const ORIGINAL_ENV = process.env; + +const setSchedulingEnv = (overrides: Record = {}) => { + 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 * * *"); + }); +}); diff --git a/apps/web/modules/survey/scheduling/lib/constants.ts b/apps/web/modules/survey/scheduling/lib/constants.ts index fa0b5deb2557..d77f2cf265d3 100644 --- a/apps/web/modules/survey/scheduling/lib/constants.ts +++ b/apps/web/modules/survey/scheduling/lib/constants.ts @@ -1,37 +1,23 @@ -const padSchedulingTimePart = (value: number): string => value.toString().padStart(2, "0"); +import { env } from "@/lib/env"; +import { type TSurveySchedulingConfig, getSurveySchedulingTimeLabel } from "./config"; -const parseSchedulingTimePart = ( - value: string | undefined, - fallback: number, - { min, max }: { min: number; max: number } -): number => { - if (!value) { - return fallback; - } +// Read at runtime from server-only env vars (no NEXT_PUBLIC_ prefix) so self-hosted Docker +// deployments can configure the scheduling execution time without rebuilding the image. Values +// are validated and normalized in apps/web/lib/env.ts (single source of truth): the time zone is +// checked against Intl.DateTimeFormat and hour/minute are coerced to their valid ranges, so a bad +// override is rejected there instead of silently reaching Intl.DateTimeFormat here. The editor UI +// receives these values as props instead of importing this module on the client. +export const SURVEY_SCHEDULING_TIME_ZONE = env.SURVEY_SCHEDULING_TIME_ZONE; +export const SURVEY_SCHEDULING_LOCAL_HOUR = env.SURVEY_SCHEDULING_LOCAL_HOUR; +export const SURVEY_SCHEDULING_LOCAL_MINUTE = env.SURVEY_SCHEDULING_LOCAL_MINUTE; - const parsedValue = Number.parseInt(value, 10); - - if (!Number.isInteger(parsedValue) || parsedValue < min || parsedValue > max) { - return fallback; - } - - return parsedValue; +export const SURVEY_SCHEDULING_CONFIG: TSurveySchedulingConfig = { + timeZone: SURVEY_SCHEDULING_TIME_ZONE, + localHour: SURVEY_SCHEDULING_LOCAL_HOUR, + localMinute: SURVEY_SCHEDULING_LOCAL_MINUTE, }; -// Use static NEXT_PUBLIC_* lookups so these values can be safely inlined into client bundles. -export const SURVEY_SCHEDULING_TIME_ZONE = - process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE ?? "Europe/Berlin"; -export const SURVEY_SCHEDULING_LOCAL_HOUR = parseSchedulingTimePart( - process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR, - 0, - { min: 0, max: 23 } -); -export const SURVEY_SCHEDULING_LOCAL_MINUTE = parseSchedulingTimePart( - process.env.NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE, - 0, - { min: 0, max: 59 } -); -export const SURVEY_SCHEDULING_TIME_LABEL = `${padSchedulingTimePart(SURVEY_SCHEDULING_LOCAL_HOUR)}:${padSchedulingTimePart(SURVEY_SCHEDULING_LOCAL_MINUTE)}`; +export const SURVEY_SCHEDULING_TIME_LABEL = getSurveySchedulingTimeLabel(SURVEY_SCHEDULING_CONFIG); export const SURVEY_SCHEDULING_TIME_ZONE_LABEL = SURVEY_SCHEDULING_TIME_ZONE; export const SURVEY_SCHEDULING_DAILY_CRON_PATTERN = `${SURVEY_SCHEDULING_LOCAL_MINUTE} ${SURVEY_SCHEDULING_LOCAL_HOUR} * * *`; export const SURVEY_SCHEDULING_GLOBAL_SCOPE = "global"; diff --git a/apps/web/modules/survey/scheduling/lib/date-utils.test.ts b/apps/web/modules/survey/scheduling/lib/date-utils.test.ts index 2a4a62c9cd42..6b0ff8c0d546 100644 --- a/apps/web/modules/survey/scheduling/lib/date-utils.test.ts +++ b/apps/web/modules/survey/scheduling/lib/date-utils.test.ts @@ -4,12 +4,19 @@ import { SURVEY_SCHEDULING_LOCAL_MINUTE, SURVEY_SCHEDULING_TIME_ZONE, } from "./constants"; -import { +import { createSurveySchedulingDateUtils, toDateOnlySelection } from "./date-utils"; + +const defaultConfig = { + timeZone: "Europe/Berlin", + localHour: 0, + localMinute: 0, +}; + +const { + toCalendarDate, getMinimumSurveySchedulingCalendarDate, normalizeDateOnlySelectionToSurveySchedulingDateTime, - toCalendarDate, - toDateOnlySelection, -} from "./date-utils"; +} = createSurveySchedulingDateUtils(defaultConfig); describe("survey scheduling date utils", () => { test("stores selected dates as noon UTC date-only values", () => { @@ -68,6 +75,19 @@ describe("survey scheduling date utils", () => { expect(minSelectableDate.getHours()).toBe(12); }); + test("honors a runtime-configured timezone, hour, and minute", () => { + const { normalizeDateOnlySelectionToSurveySchedulingDateTime: normalize } = + createSurveySchedulingDateUtils({ + timeZone: "America/New_York", + localHour: 18, + localMinute: 45, + }); + const storedDate = new Date("2026-01-17T12:00:00.000Z"); + + // 2026-01-17 18:45 in America/New_York (EST, UTC-5) is 23:45 UTC. + expect(normalize(storedDate)?.toISOString()).toBe("2026-01-17T23:45:00.000Z"); + }); + test("documents the default scheduling configuration", () => { expect(SURVEY_SCHEDULING_TIME_ZONE).toBe("Europe/Berlin"); expect(SURVEY_SCHEDULING_LOCAL_HOUR).toBe(0); diff --git a/apps/web/modules/survey/scheduling/lib/date-utils.ts b/apps/web/modules/survey/scheduling/lib/date-utils.ts index 330a66b43542..68af9b99992b 100644 --- a/apps/web/modules/survey/scheduling/lib/date-utils.ts +++ b/apps/web/modules/survey/scheduling/lib/date-utils.ts @@ -1,8 +1,4 @@ -import { - SURVEY_SCHEDULING_LOCAL_HOUR, - SURVEY_SCHEDULING_LOCAL_MINUTE, - SURVEY_SCHEDULING_TIME_ZONE, -} from "./constants"; +import type { TSurveySchedulingConfig } from "./config"; const DATE_ONLY_SELECTION_UTC_HOUR = 12; const LOCAL_CALENDAR_NOON_HOUR = 12; @@ -15,146 +11,160 @@ interface TimeZoneDateParts { year: number; } -const timeZoneFormatter = new Intl.DateTimeFormat("en-CA", { - day: "2-digit", - hour: "2-digit", - hourCycle: "h23", - minute: "2-digit", - month: "2-digit", - second: "2-digit", - timeZone: SURVEY_SCHEDULING_TIME_ZONE, - year: "numeric", -}); - -const timeZoneOffsetFormatter = new Intl.DateTimeFormat("en-US", { - hour: "2-digit", - hourCycle: "h23", - minute: "2-digit", - timeZone: SURVEY_SCHEDULING_TIME_ZONE, - timeZoneName: "shortOffset", -}); - -const getTimeZoneDateParts = (date: Date): TimeZoneDateParts => { - const parts = timeZoneFormatter.formatToParts(date); - const getPartValue = (type: Intl.DateTimeFormatPartTypes): number => { - const value = parts.find((part) => part.type === type)?.value; - - if (!value) { - throw new Error(`Missing ${type} in survey scheduling date formatter output`); - } +const createLocalCalendarDate = ({ + day, + month, + year, +}: Pick): Date => + new Date(year, month - 1, day, LOCAL_CALENDAR_NOON_HOUR, 0, 0, 0); - return Number.parseInt(value, 10); - }; +// Config-independent helpers. `toDateOnlySelection` stores the picked day as noon UTC and +// `isDateDue` compares absolute instants, so neither depends on the scheduling time zone. +export const toDateOnlySelection = (date: Date): Date => + new Date( + Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), DATE_ONLY_SELECTION_UTC_HOUR, 0, 0, 0) + ); - return { - day: getPartValue("day"), - hour: getPartValue("hour"), - minute: getPartValue("minute"), - month: getPartValue("month"), - year: getPartValue("year"), +export const isDateDue = (date: Date | null, now: Date = new Date()): boolean => + date !== null && date.getTime() <= now.getTime(); + +/** + * Builds the timezone-aware scheduling date helpers for a given configuration. The config is + * sourced from server-only env vars and passed in explicitly so the same logic can run on the + * server (reconciliation) and on the client (survey editor) with runtime-configured values. + */ +export const createSurveySchedulingDateUtils = (config: TSurveySchedulingConfig) => { + const timeZoneFormatter = new Intl.DateTimeFormat("en-CA", { + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + minute: "2-digit", + month: "2-digit", + second: "2-digit", + timeZone: config.timeZone, + year: "numeric", + }); + + const timeZoneOffsetFormatter = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + hourCycle: "h23", + minute: "2-digit", + timeZone: config.timeZone, + timeZoneName: "shortOffset", + }); + + const getTimeZoneDateParts = (date: Date): TimeZoneDateParts => { + const parts = timeZoneFormatter.formatToParts(date); + const getPartValue = (type: Intl.DateTimeFormatPartTypes): number => { + const value = parts.find((part) => part.type === type)?.value; + + if (!value) { + throw new Error(`Missing ${type} in survey scheduling date formatter output`); + } + + return Number.parseInt(value, 10); + }; + + return { + day: getPartValue("day"), + hour: getPartValue("hour"), + minute: getPartValue("minute"), + month: getPartValue("month"), + year: getPartValue("year"), + }; }; -}; -const getTimeZoneOffsetMs = (date: Date): number => { - const offsetValue = timeZoneOffsetFormatter - .formatToParts(date) - .find((part) => part.type === "timeZoneName")?.value; + const getTimeZoneOffsetMs = (date: Date): number => { + const offsetValue = timeZoneOffsetFormatter + .formatToParts(date) + .find((part) => part.type === "timeZoneName")?.value; - if (!offsetValue || offsetValue === "GMT") { - return 0; - } + if (!offsetValue || offsetValue === "GMT") { + return 0; + } - const match = /^GMT(?[+-])(?\d{1,2})(?::(?\d{2}))?$/.exec(offsetValue); + const match = /^GMT(?[+-])(?\d{1,2})(?::(?\d{2}))?$/.exec(offsetValue); - if (!match?.groups) { - throw new Error(`Unsupported survey scheduling timezone offset: ${offsetValue}`); - } + if (!match?.groups) { + throw new Error(`Unsupported survey scheduling timezone offset: ${offsetValue}`); + } - const sign = match.groups.sign === "+" ? 1 : -1; - const hours = Number.parseInt(match.groups.hours, 10); - const minutes = Number.parseInt(match.groups.minutes ?? "0", 10); + const sign = match.groups.sign === "+" ? 1 : -1; + const hours = Number.parseInt(match.groups.hours, 10); + const minutes = Number.parseInt(match.groups.minutes ?? "0", 10); - return sign * ((hours * 60 + minutes) * 60 * 1000); -}; - -const matchesSurveySchedulingLocalTime = (date: Date, year: number, month: number, day: number): boolean => { - const parts = getTimeZoneDateParts(date); + return sign * ((hours * 60 + minutes) * 60 * 1000); + }; - return ( - parts.year === year && - parts.month === month + 1 && - parts.day === day && - parts.hour === SURVEY_SCHEDULING_LOCAL_HOUR && - parts.minute === SURVEY_SCHEDULING_LOCAL_MINUTE - ); -}; + const matchesSurveySchedulingLocalTime = ( + date: Date, + year: number, + month: number, + day: number + ): boolean => { + const parts = getTimeZoneDateParts(date); + + return ( + parts.year === year && + parts.month === month + 1 && + parts.day === day && + parts.hour === config.localHour && + parts.minute === config.localMinute + ); + }; -const createSurveySchedulingDateTime = (year: number, month: number, day: number): Date => { - const utcGuessMs = Date.UTC( - year, - month, - day, - SURVEY_SCHEDULING_LOCAL_HOUR, - SURVEY_SCHEDULING_LOCAL_MINUTE, - 0, - 0 - ); - let candidate = new Date(utcGuessMs); + const createSurveySchedulingDateTime = (year: number, month: number, day: number): Date => { + const utcGuessMs = Date.UTC(year, month, day, config.localHour, config.localMinute, 0, 0); + let candidate = new Date(utcGuessMs); - for (let attempt = 0; attempt < 3; attempt++) { - candidate = new Date(utcGuessMs - getTimeZoneOffsetMs(candidate)); + for (let attempt = 0; attempt < 3; attempt++) { + candidate = new Date(utcGuessMs - getTimeZoneOffsetMs(candidate)); - if (matchesSurveySchedulingLocalTime(candidate, year, month, day)) { - return candidate; + if (matchesSurveySchedulingLocalTime(candidate, year, month, day)) { + return candidate; + } } - } - return candidate; -}; - -const createLocalCalendarDate = ({ - day, - month, - year, -}: Pick): Date => - new Date(year, month - 1, day, LOCAL_CALENDAR_NOON_HOUR, 0, 0, 0); - -export const toDateOnlySelection = (date: Date): Date => - new Date( - Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), DATE_ONLY_SELECTION_UTC_HOUR, 0, 0, 0) - ); + return candidate; + }; -export const toCalendarDate = (date: Date): Date => { - return createLocalCalendarDate(getTimeZoneDateParts(date)); -}; + const toCalendarDate = (date: Date): Date => { + return createLocalCalendarDate(getTimeZoneDateParts(date)); + }; -export const getMinimumSurveySchedulingCalendarDate = (now: Date = new Date()): Date => { - const currentSchedulingDateParts = getTimeZoneDateParts(now); - const minimumSchedulingDate = createLocalCalendarDate(currentSchedulingDateParts); - const currentSchedulingRunAt = createSurveySchedulingDateTime( - currentSchedulingDateParts.year, - currentSchedulingDateParts.month - 1, - currentSchedulingDateParts.day - ); + const getMinimumSurveySchedulingCalendarDate = (now: Date = new Date()): Date => { + const currentSchedulingDateParts = getTimeZoneDateParts(now); + const minimumSchedulingDate = createLocalCalendarDate(currentSchedulingDateParts); + const currentSchedulingRunAt = createSurveySchedulingDateTime( + currentSchedulingDateParts.year, + currentSchedulingDateParts.month - 1, + currentSchedulingDateParts.day + ); + + if (now.getTime() < currentSchedulingRunAt.getTime()) { + return minimumSchedulingDate; + } - if (now.getTime() < currentSchedulingRunAt.getTime()) { - return minimumSchedulingDate; - } + const nextSchedulingDate = new Date(minimumSchedulingDate); + nextSchedulingDate.setDate(nextSchedulingDate.getDate() + 1); + return nextSchedulingDate; + }; - const nextSchedulingDate = new Date(minimumSchedulingDate); - nextSchedulingDate.setDate(nextSchedulingDate.getDate() + 1); - return nextSchedulingDate; -}; + const normalizeDateOnlySelectionToSurveySchedulingDateTime = (date: Date | null): Date | null => { + if (!date) { + return null; + } -export const normalizeDateOnlySelectionToSurveySchedulingDateTime = (date: Date | null): Date | null => { - if (!date) { - return null; - } + const { day, month, year } = getTimeZoneDateParts(date); - const { day, month, year } = getTimeZoneDateParts(date); + return createSurveySchedulingDateTime(year, month - 1, day); + }; - return createSurveySchedulingDateTime(year, month - 1, day); + return { + toCalendarDate, + getMinimumSurveySchedulingCalendarDate, + normalizeDateOnlySelectionToSurveySchedulingDateTime, + }; }; -export const isDateDue = (date: Date | null, now: Date = new Date()): boolean => - date !== null && date.getTime() <= now.getTime(); +export type SurveySchedulingDateUtils = ReturnType; diff --git a/apps/web/modules/survey/scheduling/lib/survey-scheduling.ts b/apps/web/modules/survey/scheduling/lib/survey-scheduling.ts index 0233bf2e9f28..e2fa4378b74d 100644 --- a/apps/web/modules/survey/scheduling/lib/survey-scheduling.ts +++ b/apps/web/modules/survey/scheduling/lib/survey-scheduling.ts @@ -6,8 +6,11 @@ import { ValidationError } from "@formbricks/types/errors"; import type { TSurvey } from "@formbricks/types/surveys/types"; import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler"; import { type TAuditStatus } from "@/modules/ee/audit-logs/types/audit-log"; -import { SURVEY_SCHEDULING_RECONCILIATION_BATCH_SIZE } from "./constants"; -import { isDateDue, normalizeDateOnlySelectionToSurveySchedulingDateTime } from "./date-utils"; +import { SURVEY_SCHEDULING_CONFIG, SURVEY_SCHEDULING_RECONCILIATION_BATCH_SIZE } from "./constants"; +import { createSurveySchedulingDateUtils, isDateDue } from "./date-utils"; + +const { normalizeDateOnlySelectionToSurveySchedulingDateTime } = + createSurveySchedulingDateUtils(SURVEY_SCHEDULING_CONFIG); type TSurveySchedulingTransition = "publish" | "close"; diff --git a/apps/web/modules/workspaces/lib/utils.test.ts b/apps/web/modules/workspaces/lib/utils.test.ts index 2e566e67275c..1e2b30065e6e 100644 --- a/apps/web/modules/workspaces/lib/utils.test.ts +++ b/apps/web/modules/workspaces/lib/utils.test.ts @@ -1,9 +1,11 @@ import { redirect } from "next/navigation"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors"; import type { TMembership, TOrganizationRole } from "@formbricks/types/memberships"; import { getBillingFallbackPath } from "@/lib/membership/navigation"; import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service"; import { getOrganization } from "@/lib/organization/service"; +import { hasUserWorkspaceAccess } from "@/lib/workspace/auth"; import { getWorkspace } from "@/lib/workspace/service"; import { getSession } from "@/modules/auth/lib/session"; import { getWorkspacePermissionByUserId } from "@/modules/ee/teams/lib/roles"; @@ -11,30 +13,30 @@ import { getWorkspaceAuth } from "./utils"; const mocks = vi.hoisted(() => ({ isFormbricksCloud: false })); -// Real getAccessFlags is used on purpose so the tests exercise the actual role -> isBilling mapping -// that the redirect branch keys off of. Everything else getWorkspaceAuth touches is stubbed. +// Real getAccessFlags and getTeamPermissionFlags are used on purpose so the tests exercise the +// actual role -> isBilling mapping (the redirect branch) and the permission -> isReadOnly mapping. +// Everything else getWorkspaceAuth touches is stubbed. next/navigation is globally mocked in +// vitestSetup.ts, so `redirect` is a no-op spy here. vi.mock("react", () => ({ cache: (fn: (...args: unknown[]) => unknown) => fn })); vi.mock("@/lib/constants", async (importOriginal) => ({ ...(await importOriginal()), IS_FORMBRICKS_CLOUD: mocks.isFormbricksCloud, })); vi.mock("@/lib/workspace/service", () => ({ getWorkspace: vi.fn() })); -vi.mock("@/lib/organization/service", () => ({ getOrganization: vi.fn() })); +vi.mock("@/lib/workspace/auth", () => ({ hasUserWorkspaceAccess: vi.fn() })); +vi.mock("@/lib/organization/service", () => ({ + getOrganization: vi.fn(), + getMonthlyOrganizationResponseCount: vi.fn(), +})); vi.mock("@/lib/membership/service", () => ({ getMembershipByUserIdOrganizationId: vi.fn() })); vi.mock("@/lib/membership/navigation", () => ({ getBillingFallbackPath: vi.fn() })); vi.mock("@/lingodotdev/server", () => ({ getTranslate: vi.fn(() => Promise.resolve((k: string) => k)) })); vi.mock("@/modules/auth/lib/session", () => ({ getSession: vi.fn() })); vi.mock("@/modules/ee/teams/lib/roles", () => ({ getWorkspacePermissionByUserId: vi.fn() })); -vi.mock("@/modules/ee/teams/utils/teams", () => ({ - getTeamPermissionFlags: vi.fn(() => ({ - hasReadAccess: false, - hasReadWriteAccess: false, - hasManageAccess: false, - })), -})); const workspaceId = "workspace-1"; const organizationId = "organization-1"; +const userId = "user-1"; const billingFallbackPath = `/organizations/${organizationId}/settings/enterprise`; const primeAuth = (role: TOrganizationRole) => { @@ -42,7 +44,7 @@ const primeAuth = (role: TOrganizationRole) => { ReturnType >); vi.mocked(getSession).mockResolvedValue({ - user: { id: "user-1" }, + user: { id: userId }, expires: new Date(0).toISOString(), } as Awaited>); vi.mocked(getOrganization).mockResolvedValue({ id: organizationId } as Awaited< @@ -51,9 +53,10 @@ const primeAuth = (role: TOrganizationRole) => { vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue({ role, organizationId, - userId: "user-1", + userId, accepted: true, } as TMembership); + vi.mocked(hasUserWorkspaceAccess).mockResolvedValue(true); vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null); vi.mocked(getBillingFallbackPath).mockReturnValue(billingFallbackPath); }; @@ -85,3 +88,91 @@ describe("getWorkspaceAuth billing gate (ENG-1763)", () => { } ); }); + +describe("getWorkspaceAuth workspace-access gate + isReadOnly (ENG-1769)", () => { + beforeEach(() => { + vi.clearAllMocks(); + // Happy-path default: an org member with read-only workspace access. + primeAuth("member"); + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("read"); + }); + + test("throws ResourceNotFoundError when the workspace is missing", async () => { + vi.mocked(getWorkspace).mockResolvedValue(null); + await expect(getWorkspaceAuth(workspaceId)).rejects.toThrow(ResourceNotFoundError); + }); + + test("throws AuthenticationError when there is no session", async () => { + vi.mocked(getSession).mockResolvedValue(null); + await expect(getWorkspaceAuth(workspaceId)).rejects.toThrow(AuthenticationError); + }); + + test("throws ResourceNotFoundError when the organization is missing", async () => { + vi.mocked(getOrganization).mockResolvedValue(null); + await expect(getWorkspaceAuth(workspaceId)).rejects.toThrow(ResourceNotFoundError); + }); + + test("throws AuthorizationError when the user has no org membership", async () => { + vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(null); + await expect(getWorkspaceAuth(workspaceId)).rejects.toThrow(AuthorizationError); + }); + + // The core fix: an org member with no WorkspaceTeam grant for this workspace + // must be rejected instead of being admitted (and mislabeled as a writer). + test("throws AuthorizationError when the user has no workspace access", async () => { + vi.mocked(hasUserWorkspaceAccess).mockResolvedValue(false); + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null); + await expect(getWorkspaceAuth(workspaceId)).rejects.toThrow(AuthorizationError); + expect(hasUserWorkspaceAccess).toHaveBeenCalledWith(userId, workspaceId); + }); + + test("marks a member with a read grant as read-only", async () => { + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("read"); + const auth = await getWorkspaceAuth(workspaceId); + expect(auth.isMember).toBe(true); + expect(auth.hasReadAccess).toBe(true); + expect(auth.isReadOnly).toBe(true); + }); + + test("does not mark a member with a readWrite grant as read-only", async () => { + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("readWrite"); + const auth = await getWorkspaceAuth(workspaceId); + expect(auth.hasReadWriteAccess).toBe(true); + expect(auth.isReadOnly).toBe(false); + }); + + test("does not mark a member with a manage grant as read-only", async () => { + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue("manage"); + const auth = await getWorkspaceAuth(workspaceId); + expect(auth.hasManageAccess).toBe(true); + expect(auth.isReadOnly).toBe(false); + }); + + // Fail-safe: even if a member reaches the flags with no resolved permission, + // isReadOnly must be true (most restricted), never false (writer). + test("treats a member with no resolved permission as read-only (fail safe)", async () => { + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null); + const auth = await getWorkspaceAuth(workspaceId); + expect(auth.isMember).toBe(true); + expect(auth.hasReadAccess).toBe(false); + expect(auth.hasReadWriteAccess).toBe(false); + expect(auth.hasManageAccess).toBe(false); + expect(auth.isReadOnly).toBe(true); + }); + + test("does not mark an owner as read-only", async () => { + primeAuth("owner"); + vi.mocked(getWorkspacePermissionByUserId).mockResolvedValue(null); + const auth = await getWorkspaceAuth(workspaceId); + expect(auth.isOwner).toBe(true); + expect(auth.isReadOnly).toBe(false); + }); + + test("returns the resolved workspace, organization, and session on success", async () => { + const auth = await getWorkspaceAuth(workspaceId); + expect(auth.workspace).toMatchObject({ id: workspaceId, organizationId }); + expect(auth.organization).toMatchObject({ id: organizationId }); + expect(auth.session.user.id).toBe(userId); + expect(auth.workspacePermission).toBe("read"); + }); +}); diff --git a/apps/web/modules/workspaces/lib/utils.ts b/apps/web/modules/workspaces/lib/utils.ts index abaffe2c2432..ff392ec9734a 100644 --- a/apps/web/modules/workspaces/lib/utils.ts +++ b/apps/web/modules/workspaces/lib/utils.ts @@ -28,9 +28,13 @@ import { getTeamPermissionFlags } from "@/modules/ee/teams/utils/teams"; import { TWorkspaceAuth, TWorkspaceLayoutData } from "@/modules/workspaces/types/workspace-auth"; /** - * Workspace-scoped equivalent of getEnvironmentAuth. - * Accepts a workspaceId, resolves the production environment automatically, - * and performs the same authorization checks as getEnvironmentAuth. + * Resolves a workspace and returns the caller's authorization context for it. + * + * This helper is self-contained: it enforces workspace-level access itself rather + * than relying on the route layout to gate, so it is safe to reuse from any page or + * route. Billing-role members are redirected to billing/enterprise screens; any org + * member without a WorkspaceTeam grant (and who is not an owner/manager) is rejected + * with an AuthorizationError instead of being silently admitted as a writer. */ export const getWorkspaceAuth = reactCache(async (workspaceId: string): Promise => { const t = await getTranslate(); @@ -67,11 +71,26 @@ export const getWorkspaceAuth = reactCache(async (workspaceId: string): Promise< redirect(getBillingFallbackPath(organization.id, IS_FORMBRICKS_CLOUD)); } - const workspacePermission = await getWorkspacePermissionByUserId(session.user.id, workspace.id); + // Enforce workspace access here instead of delegating to the route layout, so + // getWorkspaceAuth is safe to reuse anywhere. An org member with no WorkspaceTeam + // grant for this workspace has no access and must be rejected — not silently + // treated as a writer. Runs alongside the permission lookup to avoid extra latency. + const [hasWorkspaceAccess, workspacePermission] = await Promise.all([ + hasUserWorkspaceAccess(session.user.id, workspace.id), + getWorkspacePermissionByUserId(session.user.id, workspace.id), + ]); + + if (!hasWorkspaceAccess) { + throw new AuthorizationError(t("common.not_authorized")); + } const { hasReadAccess, hasReadWriteAccess, hasManageAccess } = getTeamPermissionFlags(workspacePermission); - const isReadOnly = isMember && hasReadAccess; + // Fail safe: a member is read-only unless they hold an explicit write or manage + // grant. Deriving from the *absence* of write access (rather than the presence of + // an exact "read" grant) means a member with no resolved permission is treated as + // the most restricted, never mislabeled as a writer. + const isReadOnly = isMember && !hasReadWriteAccess && !hasManageAccess; return { workspace, diff --git a/turbo.json b/turbo.json index 163413ca45ec..503d48f74421 100644 --- a/turbo.json +++ b/turbo.json @@ -218,10 +218,7 @@ "WEBAPP_URL", "NEXTAUTH_URL", "S3_ENDPOINT_URL", - "POSTHOG_KEY", - "NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE", - "NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR", - "NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE" + "POSTHOG_KEY" ], "outputs": ["dist/**", ".next/**", "!.next/cache/**", "!.next/dev/**"], "passThroughEnv": [ @@ -348,6 +345,9 @@ "STRIPE_PUBLISHABLE_KEY", "SURVEYS_PACKAGE_MODE", "SURVEYS_PACKAGE_BUILD", + "SURVEY_SCHEDULING_TIME_ZONE", + "SURVEY_SCHEDULING_LOCAL_HOUR", + "SURVEY_SCHEDULING_LOCAL_MINUTE", "PUBLIC_URL", "TURNSTILE_SECRET_KEY", "TURNSTILE_SITE_KEY",