diff --git a/apps/web/__tests__/mocks/email-provider.mock.ts b/apps/web/__tests__/mocks/email-provider.mock.ts index dd65a3b874..36355c200e 100644 --- a/apps/web/__tests__/mocks/email-provider.mock.ts +++ b/apps/web/__tests__/mocks/email-provider.mock.ts @@ -139,7 +139,7 @@ export function createMockEmailProvider( .mockResolvedValue(false), isReplyInThread: vi.fn().mockReturnValue(false), isSentMessage: vi.fn().mockReturnValue(false), - getOrCreateOutlookFolderIdByName: vi.fn().mockResolvedValue("folder-123"), + getOrCreateFolderIdByName: vi.fn().mockResolvedValue("folder-123"), getSignatures: vi.fn().mockResolvedValue([]), // Watch/webhooks diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx index 1b3c081192..c8a32c150f 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/AssistantTabs.tsx @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Rules } from "@/app/(app)/[emailAccountId]/assistant/Rules"; import { Process } from "@/app/(app)/[emailAccountId]/assistant/Process"; import { RulesPrompt } from "@/app/(app)/[emailAccountId]/assistant/RulesPrompt"; +import { SettingsTab } from "@/app/(app)/[emailAccountId]/assistant/settings/SettingsTab"; import { TabsToolbar } from "@/components/TabsToolbar"; import { TypographyP } from "@/components/Typography"; import { RuleTab } from "@/app/(app)/[emailAccountId]/assistant/RuleTab"; @@ -24,6 +25,7 @@ export function AssistantTabs() { Rules Test History + Settings @@ -50,6 +52,9 @@ export function AssistantTabs() { + + + {/* Set via search params. Not a visible tab. */} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/RuleImportExportSetting.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/RuleImportExportSetting.tsx new file mode 100644 index 0000000000..41f14bef4a --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/RuleImportExportSetting.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useCallback, useRef } from "react"; +import { toast } from "sonner"; +import { DownloadIcon, UploadIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { SettingCard } from "@/components/SettingCard"; +import { toastError } from "@/components/Toast"; +import { useRules } from "@/hooks/useRules"; +import { useAccount } from "@/providers/EmailAccountProvider"; +import { importRulesAction } from "@/utils/actions/rule"; + +export function RuleImportExportSetting() { + const { data, mutate } = useRules(); + const { emailAccountId } = useAccount(); + const fileInputRef = useRef(null); + + const exportRules = useCallback(() => { + if (!data) return; + + const exportData = data.map((rule) => ({ + name: rule.name, + instructions: rule.instructions, + enabled: rule.enabled, + automate: rule.automate, + runOnThreads: rule.runOnThreads, + systemType: rule.systemType, + conditionalOperator: rule.conditionalOperator, + from: rule.from, + to: rule.to, + subject: rule.subject, + body: rule.body, + categoryFilterType: rule.categoryFilterType, + actions: rule.actions.map((action) => ({ + type: action.type, + label: action.label, + to: action.to, + cc: action.cc, + bcc: action.bcc, + subject: action.subject, + content: action.content, + folderName: action.folderName, + url: action.url, + delayInMinutes: action.delayInMinutes, + })), + // note: group associations are not exported as they require matching group IDs + })); + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `inbox-zero-rules-${new Date().toISOString().split("T")[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + toast.success("Rules exported successfully"); + }, [data]); + + const importRules = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + const text = await file.text(); + const rules = JSON.parse(text); + + const rulesArray = Array.isArray(rules) ? rules : rules.rules; + + if (!Array.isArray(rulesArray) || rulesArray.length === 0) { + toastError({ description: "Invalid rules file format" }); + return; + } + + const result = await importRulesAction(emailAccountId, { + rules: rulesArray, + }); + + if (result?.serverError) { + toastError({ + title: "Import failed", + description: result.serverError, + }); + } else if (result?.data) { + const { createdCount, updatedCount, skippedCount } = result.data; + toast.success( + `Imported ${createdCount} new, updated ${updatedCount} existing${skippedCount > 0 ? `, skipped ${skippedCount}` : ""}`, + ); + mutate(); + } + } catch (error) { + toastError({ + title: "Import failed", + description: + error instanceof Error ? error.message : "Failed to parse file", + }); + } + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }, + [emailAccountId, mutate], + ); + + return ( + + + + + + } + /> + ); +} diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx index b84d12ac9a..878f14c090 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/settings/SettingsTab.tsx @@ -7,6 +7,7 @@ import { LearnedPatternsSetting } from "@/app/(app)/[emailAccountId]/assistant/s import { PersonalSignatureSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/PersonalSignatureSetting"; import { MultiRuleSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/MultiRuleSetting"; import { WritingStyleSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/WritingStyleSetting"; +import { RuleImportExportSetting } from "@/app/(app)/[emailAccountId]/assistant/settings/RuleImportExportSetting"; import { env } from "@/env"; export function SettingsTab() { @@ -21,6 +22,7 @@ export function SettingsTab() { {env.NEXT_PUBLIC_DIGEST_ENABLED && } + ); } diff --git a/apps/web/env.ts b/apps/web/env.ts index 5954715c4a..54591b6171 100644 --- a/apps/web/env.ts +++ b/apps/web/env.ts @@ -190,6 +190,7 @@ export const env = createEnv({ NEXT_PUBLIC_DIGEST_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_MEETING_BRIEFS_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_INTEGRATIONS_ENABLED: z.coerce.boolean().optional(), + NEXT_PUBLIC_CLEANER_ENABLED: z.coerce.boolean().optional(), NEXT_PUBLIC_IS_RESEND_CONFIGURED: z.coerce.boolean().optional(), }, // For Next.js >= 13.4.4, you only need to destructure client variables: @@ -252,6 +253,7 @@ export const env = createEnv({ process.env.NEXT_PUBLIC_MEETING_BRIEFS_ENABLED, NEXT_PUBLIC_INTEGRATIONS_ENABLED: process.env.NEXT_PUBLIC_INTEGRATIONS_ENABLED, + NEXT_PUBLIC_CLEANER_ENABLED: process.env.NEXT_PUBLIC_CLEANER_ENABLED, NEXT_PUBLIC_IS_RESEND_CONFIGURED: process.env.NEXT_PUBLIC_IS_RESEND_CONFIGURED, }, diff --git a/apps/web/hooks/useFeatureFlags.ts b/apps/web/hooks/useFeatureFlags.ts index 0434bbf34f..d74170cd2e 100644 --- a/apps/web/hooks/useFeatureFlags.ts +++ b/apps/web/hooks/useFeatureFlags.ts @@ -5,7 +5,8 @@ import { import { env } from "@/env"; export function useCleanerEnabled() { - return useFeatureFlagEnabled("inbox-cleaner"); + const posthogEnabled = useFeatureFlagEnabled("inbox-cleaner"); + return env.NEXT_PUBLIC_CLEANER_ENABLED || posthogEnabled; } export function useMeetingBriefsEnabled() { diff --git a/apps/web/utils/__mocks__/email-provider.ts b/apps/web/utils/__mocks__/email-provider.ts index cc974d81bd..6810419245 100644 --- a/apps/web/utils/__mocks__/email-provider.ts +++ b/apps/web/utils/__mocks__/email-provider.ts @@ -131,7 +131,7 @@ export const createMockEmailProvider = ( getThreadsFromSenderWithSubject: vi.fn().mockResolvedValue([]), processHistory: vi.fn().mockResolvedValue(undefined), moveThreadToFolder: vi.fn().mockResolvedValue(undefined), - getOrCreateOutlookFolderIdByName: vi.fn().mockResolvedValue("folder1"), + getOrCreateFolderIdByName: vi.fn().mockResolvedValue("folder1"), sendEmailWithHtml: vi.fn().mockResolvedValue(undefined), getDrafts: vi.fn().mockResolvedValue([]), ...overrides, diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index f2e1a6974b..a36f198814 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -16,6 +16,8 @@ import { toggleRuleBody, toggleAllRulesBody, copyRulesFromAccountBody, + importRulesBody, + type ImportedRule, } from "@/utils/actions/rule.validation"; import prisma from "@/utils/prisma"; import { isDuplicateError, isNotFoundError } from "@/utils/prisma-helpers"; @@ -673,7 +675,7 @@ async function toggleRule({ for (const actionType of actionTypes) { if (actionType.includeFolder) { - const folderId = await emailProvider.getOrCreateOutlookFolderIdByName( + const folderId = await emailProvider.getOrCreateFolderIdByName( ruleConfig.name, ); actions.push({ @@ -839,7 +841,7 @@ async function resolveActionLabels< const folderName = action.folderName?.value; if (folderName && !action.folderId?.value) { const resolvedFolderId = - await emailProvider.getOrCreateOutlookFolderIdByName(folderName); + await emailProvider.getOrCreateFolderIdByName(folderName); return { ...action, folderId: { @@ -930,7 +932,7 @@ async function getActionsFromCategoryAction({ } case ActionType.MOVE_FOLDER: { const folderId = - await emailProvider.getOrCreateOutlookFolderIdByName(ruleName); + await emailProvider.getOrCreateFolderIdByName(ruleName); logger.info("Resolved folder ID during onboarding", { folderName: ruleName, @@ -961,3 +963,114 @@ async function getActionsFromCategoryAction({ return actions; } + +export const importRulesAction = actionClient + .metadata({ name: "importRules" }) + .inputSchema(importRulesBody) + .action( + async ({ ctx: { emailAccountId, logger }, parsedInput: { rules } }) => { + logger.info("Importing rules", { count: rules.length }); + + // Fetch existing rules to check for duplicates by name or systemType + const existingRules = await prisma.rule.findMany({ + where: { emailAccountId }, + select: { id: true, name: true, systemType: true }, + }); + + const rulesByName = new Map( + existingRules.map((r) => [r.name.toLowerCase(), r.id]), + ); + const rulesBySystemType = new Map( + existingRules + .filter((r) => r.systemType) + .map((r) => [r.systemType!, r.id]), + ); + + let createdCount = 0; + let updatedCount = 0; + let skippedCount = 0; + + for (const rule of rules) { + try { + // Match by systemType first, then by name + const existingRuleId = rule.systemType + ? rulesBySystemType.get(rule.systemType) + : rulesByName.get(rule.name.toLowerCase()); + + // Map actions - keep label names but clear IDs + const mappedActions = rule.actions.map((action) => ({ + type: action.type, + label: action.label, + labelId: null, + subject: action.subject, + content: action.content, + to: action.to, + cc: action.cc, + bcc: action.bcc, + folderName: action.folderName, + folderId: null, + url: action.url, + delayInMinutes: action.delayInMinutes, + })); + + if (existingRuleId) { + // Update existing rule + await prisma.rule.update({ + where: { id: existingRuleId }, + data: { + instructions: rule.instructions, + enabled: rule.enabled ?? true, + automate: rule.automate ?? true, + runOnThreads: rule.runOnThreads ?? false, + conditionalOperator: rule.conditionalOperator, + categoryFilterType: rule.categoryFilterType, + from: rule.from, + to: rule.to, + subject: rule.subject, + body: rule.body, + groupId: null, + actions: { + deleteMany: {}, + createMany: { data: mappedActions }, + }, + }, + }); + updatedCount++; + } else { + // Create new rule + await prisma.rule.create({ + data: { + emailAccountId, + name: rule.name, + systemType: rule.systemType, + instructions: rule.instructions, + enabled: rule.enabled ?? true, + automate: rule.automate ?? true, + runOnThreads: rule.runOnThreads ?? false, + conditionalOperator: rule.conditionalOperator, + categoryFilterType: rule.categoryFilterType, + from: rule.from, + to: rule.to, + subject: rule.subject, + body: rule.body, + groupId: null, + actions: { createMany: { data: mappedActions } }, + }, + }); + createdCount++; + } + } catch (error) { + logger.error("Failed to import rule", { ruleName: rule.name, error }); + skippedCount++; + } + } + + logger.info("Import complete", { + createdCount, + updatedCount, + skippedCount, + }); + + return { createdCount, updatedCount, skippedCount }; + }, + ); diff --git a/apps/web/utils/actions/rule.validation.ts b/apps/web/utils/actions/rule.validation.ts index 083548ea13..73a4e66c70 100644 --- a/apps/web/utils/actions/rule.validation.ts +++ b/apps/web/utils/actions/rule.validation.ts @@ -279,3 +279,105 @@ export const copyRulesFromAccountBody = z.object({ ruleIds: z.array(z.string()).min(1, "Select at least one rule to copy"), }); export type CopyRulesFromAccountBody = z.infer; + +// Schema for importing rules from JSON export +const importedAction = z + .object({ + type: zodActionType, + label: z.string().nullish(), + to: z.string().nullish(), + cc: z.string().nullish(), + bcc: z.string().nullish(), + subject: z.string().nullish(), + content: z.string().nullish(), + folderName: z.string().nullish(), + url: z.string().nullish(), + delayInMinutes: delayInMinutesSchema, + }) + .superRefine((data, ctx) => { + if (data.type === ActionType.LABEL) { + const labelValue = data.label?.trim(); + + if (!labelValue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Label action requires a label name", + path: ["label"], + }); + return; + } + + const validation = validateLabelNameBasic(labelValue); + if (!validation.valid) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: validation.error!, + path: ["label"], + }); + } + } + + if (data.type === ActionType.FORWARD && !data.to?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Forward action requires a recipient email address", + path: ["to"], + }); + } + + if (data.type === ActionType.CALL_WEBHOOK && !data.url?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Webhook action requires a URL", + path: ["url"], + }); + } + + if (data.type === ActionType.MOVE_FOLDER && !data.folderName?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Move folder action requires a folder name", + path: ["folderName"], + }); + } + }); + +const importedRule = z + .object({ + name: z.string().min(1), + instructions: z.string().nullish(), + enabled: z.boolean().optional().default(true), + automate: z.boolean().optional().default(true), + runOnThreads: z.boolean().optional().default(false), + systemType: zodSystemRule.nullish(), + conditionalOperator: z + .enum([LogicalOperator.AND, LogicalOperator.OR]) + .optional() + .default(LogicalOperator.AND), + from: z.string().nullish(), + to: z.string().nullish(), + subject: z.string().nullish(), + body: z.string().nullish(), + categoryFilterType: z.string().nullish(), + actions: z.array(importedAction).min(1), + group: z.string().nullish(), + }) + .refine( + (data) => + data.systemType || + data.from?.trim() || + data.to?.trim() || + data.subject?.trim() || + data.body?.trim() || + data.instructions?.trim(), + { + message: + "At least one condition (from, to, subject, body, or instructions) must be provided", + }, + ); + +export const importRulesBody = z.object({ + rules: z.array(importedRule).min(1, "No rules to import"), +}); +export type ImportRulesBody = z.infer; +export type ImportedRule = z.infer; diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index a06555e410..cc584fccf3 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -318,14 +318,46 @@ const digest: ActionFunction<{ id?: string }> = async ({ await enqueueDigestItem({ email, emailAccountId, actionId, logger }); }; -const move_folder: ActionFunction<{ folderId?: string | null }> = async ({ - client, - email, - userEmail, - args, -}) => { - if (!args.folderId) return; - await client.moveThreadToFolder(email.threadId, userEmail, args.folderId); +const move_folder: ActionFunction<{ + folderId?: string | null; + folderName?: string | null; +}> = async ({ client, email, userEmail, emailAccountId, args, logger }) => { + const originalFolderId = args.folderId; + let folderIdToUse = originalFolderId; + + // resolve folder name to ID if needed (similar to label resolution) + if (!folderIdToUse && args.folderName) { + if (hasVariables(args.folderName)) { + logger.error("Template folder name not processed by AI", { + folderName: args.folderName, + }); + return; + } + + logger.info("Resolving folder name to ID", { folderName: args.folderName }); + folderIdToUse = await client.getOrCreateFolderIdByName(args.folderName); + + if (!folderIdToUse) { + logger.error("Failed to resolve folder", { folderName: args.folderName }); + return; + } + } + + if (!folderIdToUse) return; + + await client.moveThreadToFolder(email.threadId, userEmail, folderIdToUse); + + // lazy-update the folderId in the database for future runs + if (!originalFolderId && folderIdToUse && args.folderName) { + after(() => + lazyUpdateActionFolderId({ + folderName: args.folderName!, + folderId: folderIdToUse!, + emailAccountId, + logger, + }), + ); + } }; const notify_sender: ActionFunction> = async ({ @@ -401,3 +433,38 @@ async function lazyUpdateActionLabelId({ }); } } + +async function lazyUpdateActionFolderId({ + folderName, + folderId, + emailAccountId, + logger, +}: { + folderName: string; + folderId: string; + emailAccountId: string; + logger: Logger; +}) { + try { + const result = await prisma.action.updateMany({ + where: { + folderName, + folderId: null, + rule: { emailAccountId }, + }, + data: { folderId }, + }); + + if (result.count > 0) { + logger.info("Lazy-updated Action folderId", { + folderId, + updatedCount: result.count, + }); + } + } catch (error) { + logger.warn("Failed to lazy-update Action folderId", { + folderId, + error, + }); + } +} diff --git a/apps/web/utils/email/google.ts b/apps/web/utils/email/google.ts index 59b237e1d2..37ee6fbb32 100644 --- a/apps/web/utils/email/google.ts +++ b/apps/web/utils/email/google.ts @@ -1286,8 +1286,8 @@ export class GmailProvider implements EmailProvider { this.logger.warn("Moving thread to folder is not supported for Gmail"); } - async getOrCreateOutlookFolderIdByName(_folderName: string): Promise { - this.logger.warn("Moving thread to folder is not supported for Gmail"); + async getOrCreateFolderIdByName(_folderName: string): Promise { + this.logger.warn("Moving to folder is not supported for Gmail"); return ""; } diff --git a/apps/web/utils/email/microsoft.ts b/apps/web/utils/email/microsoft.ts index 355e00f29c..28aa224c5c 100644 --- a/apps/web/utils/email/microsoft.ts +++ b/apps/web/utils/email/microsoft.ts @@ -1643,7 +1643,7 @@ export class OutlookProvider implements EmailProvider { }); } - async getOrCreateOutlookFolderIdByName(folderName: string): Promise { + async getOrCreateFolderIdByName(folderName: string): Promise { return await getOrCreateOutlookFolderIdByName( this.client, folderName, diff --git a/apps/web/utils/email/types.ts b/apps/web/utils/email/types.ts index 95b5234457..fcacbbc210 100644 --- a/apps/web/utils/email/types.ts +++ b/apps/web/utils/email/types.ts @@ -246,6 +246,6 @@ export interface EmailProvider { ownerEmail: string, folderName: string, ): Promise; - getOrCreateOutlookFolderIdByName(folderName: string): Promise; + getOrCreateFolderIdByName(folderName: string): Promise; getSignatures(): Promise; } diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 39ad471ec7..acb9f25b0d 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -338,8 +338,7 @@ async function mapActionFields( logger, }); - folderId = - await emailProvider.getOrCreateOutlookFolderIdByName(folderName); + folderId = await emailProvider.getOrCreateFolderIdByName(folderName); } return { diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index 549d191556..09a13d6bc6 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -36,6 +36,14 @@ ARG NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS="NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS_PLACEHO ENV NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS=${NEXT_PUBLIC_BYPASS_PREMIUM_CHECKS} ARG NEXT_PUBLIC_EMAIL_SEND_ENABLED="NEXT_PUBLIC_EMAIL_SEND_ENABLED_PLACEHOLDER" ENV NEXT_PUBLIC_EMAIL_SEND_ENABLED=${NEXT_PUBLIC_EMAIL_SEND_ENABLED} +ARG NEXT_PUBLIC_CLEANER_ENABLED="NEXT_PUBLIC_CLEANER_ENABLED_PLACEHOLDER" +ENV NEXT_PUBLIC_CLEANER_ENABLED=${NEXT_PUBLIC_CLEANER_ENABLED} +ARG NEXT_PUBLIC_MEETING_BRIEFS_ENABLED="NEXT_PUBLIC_MEETING_BRIEFS_ENABLED_PLACEHOLDER" +ENV NEXT_PUBLIC_MEETING_BRIEFS_ENABLED=${NEXT_PUBLIC_MEETING_BRIEFS_ENABLED} +ARG NEXT_PUBLIC_INTEGRATIONS_ENABLED="NEXT_PUBLIC_INTEGRATIONS_ENABLED_PLACEHOLDER" +ENV NEXT_PUBLIC_INTEGRATIONS_ENABLED=${NEXT_PUBLIC_INTEGRATIONS_ENABLED} +ARG NEXT_PUBLIC_DIGEST_ENABLED="NEXT_PUBLIC_DIGEST_ENABLED_PLACEHOLDER" +ENV NEXT_PUBLIC_DIGEST_ENABLED=${NEXT_PUBLIC_DIGEST_ENABLED} # Provide safe dummy envs so Next build can complete at image build time ENV DATABASE_URL="postgresql://dummy:dummy@dummy:5432/dummy?schema=public" diff --git a/docker/scripts/start.sh b/docker/scripts/start.sh index beff121641..61c45ca9fa 100755 --- a/docker/scripts/start.sh +++ b/docker/scripts/start.sh @@ -20,6 +20,22 @@ if [ -n "$NEXT_PUBLIC_EMAIL_SEND_ENABLED" ]; then /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_EMAIL_SEND_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_EMAIL_SEND_ENABLED" fi +if [ -n "$NEXT_PUBLIC_CLEANER_ENABLED" ]; then + /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_CLEANER_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_CLEANER_ENABLED" +fi + +if [ -n "$NEXT_PUBLIC_MEETING_BRIEFS_ENABLED" ]; then + /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_MEETING_BRIEFS_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_MEETING_BRIEFS_ENABLED" +fi + +if [ -n "$NEXT_PUBLIC_INTEGRATIONS_ENABLED" ]; then + /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_INTEGRATIONS_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_INTEGRATIONS_ENABLED" +fi + +if [ -n "$NEXT_PUBLIC_DIGEST_ENABLED" ]; then + /app/docker/scripts/replace-placeholder.sh "NEXT_PUBLIC_DIGEST_ENABLED_PLACEHOLDER" "$NEXT_PUBLIC_DIGEST_ENABLED" +fi + if [ -n "$DATABASE_URL" ]; then echo "🔄 Running database migrations..." if timeout 320 prisma migrate deploy --schema=./apps/web/prisma/schema.prisma; then