import { defaultBrandTokens } from "@toolctrl/brand"; import { loadRequiredWebConfig } from "@toolctrl/config"; import { Prisma } from "@toolctrl/db/prisma-browser"; import { getTranslations } from "~/locale/i18next.server"; import { getPublicAuthProviders } from "../auth/authProviders.server"; import { cachified, clearCacheKey } from "../cache.server"; import { db } from "../db.server"; import { defaultTheme } from "../theme/defaultThemes"; import { getBaseURL, getDomainName } from "../url.server"; import { PortalConfiguration, getAppPortalConfiguration } from "./appPortalConfiguration.db.server"; import { getWidgetsConfiguration, WidgetsConfiguration } from "./appWidgetsConfiguration.db.server"; export type AppConfiguration = { app: { name: string; url: string; domain: string; theme: string; company?: { name: string; address: string }; features: { tenantHome: "/:tenant/" | "/"; tenantTypes: boolean; tenantApiKeys?: boolean; tenantEntityCustomization?: boolean; crm: boolean; tenantWorkflows: boolean; tenantEmailMarketing: boolean; tenantFeedback: boolean; }; }; email: { provider: "mailjet"; fromEmail: string; fromName: string; supportEmail: string; }; auth: { requireEmailVerification: boolean; requireOrganization: boolean; requireName: boolean; slug: { require: boolean; type: "tenant" | "username" } | null; captcha: { enabled: boolean; instanceUrl: string; loginSiteKey: string; }; authMethods: { emailPassword: { enabled: boolean; }; magicLink: { enabled: boolean; }; }; }; analytics: { enabled: boolean; simpleAnalytics: boolean; plausibleAnalytics: boolean; // EU-Sterilisation (R-P1a): googleAnalyticsTrackingId entfernt. DB-Spalte bleibt vorerst // verwaist (Drop per Merge-Migration, AD-21), wird aber nicht mehr gelesen/geschrieben. ipLookup?: boolean; }; subscription: { required: boolean; allowSubscribeBeforeSignUp: boolean; allowSignUpBeforeSubscribe: boolean; multiple: boolean; }; cookies: { enabled: boolean; }; notifications: { enabled: boolean; }; onboarding: { enabled: boolean; }; featureFlags: { enabled: boolean; // load all flags }; metrics: { enabled: boolean; logToConsole: boolean; saveToDatabase: boolean; ignoreUrls: string[]; }; branding: { logo?: string; logoDarkMode?: string; icon?: string; iconDarkMode?: string; favicon?: string; }; reviews?: { trustpilot?: { href: string; templateId: string; businessUnitId: string }; }; launches?: { producthunt?: { title: string; url: string; postId: string }; }; portals: PortalConfiguration; widgets: WidgetsConfiguration; scripts: { head: string | null; body: string | null }; }; export async function getAppConfiguration({ request }: { request: Request }): Promise { const { t } = await getTranslations(request); const webConfig = loadRequiredWebConfig(); const authProviders = getPublicAuthProviders({ request, webConfig }); const defaultEmailConfig: AppConfiguration["email"] = { provider: "mailjet", fromEmail: defaultBrandTokens.support.email, fromName: defaultBrandTokens.support.fromName, supportEmail: defaultBrandTokens.support.email, }; const conf: AppConfiguration = { app: { name: webConfig.appName, url: getBaseURL(request), domain: getDomainName(request), theme: defaultTheme, company: undefined, features: { tenantHome: "/:tenant/", tenantApiKeys: true, tenantEntityCustomization: false, crm: false, tenantTypes: false, tenantWorkflows: false, tenantEmailMarketing: false, tenantFeedback: true, }, }, email: defaultEmailConfig, auth: { requireEmailVerification: false, requireOrganization: false, requireName: false, slug: { require: false, type: "username" }, captcha: { enabled: Boolean(webConfig.captcha.instanceUrl && webConfig.captcha.loginSiteKey), instanceUrl: webConfig.captcha.instanceUrl ?? "", loginSiteKey: webConfig.captcha.loginSiteKey ?? "", }, authMethods: { emailPassword: { enabled: true, }, magicLink: { enabled: authProviders.magicLink.enabled, }, }, }, analytics: { enabled: false, simpleAnalytics: false, plausibleAnalytics: false, ipLookup: false, }, subscription: { required: false, allowSubscribeBeforeSignUp: true, allowSignUpBeforeSubscribe: true, // `multiple: false` = single-plan-per-tenant. When a user subscribes to a // new plan while an old one is still active, the old one is auto-cancelled // (see `addTenantProductsFromCheckoutSession` in pricingService). This is // the standard SaaS upgrade/downgrade behaviour. Flip to `true` only if // you want tenants to be able to hold multiple parallel subscriptions // (e.g. add-on / marketplace pattern). multiple: false, }, cookies: { enabled: false, }, notifications: { enabled: true, }, onboarding: { enabled: true, }, featureFlags: { enabled: true, }, metrics: { enabled: false, logToConsole: false, saveToDatabase: false, ignoreUrls: ["/build", "/metrics"], }, branding: { logo: undefined, logoDarkMode: undefined, icon: undefined, iconDarkMode: undefined, favicon: undefined, }, launches: undefined, portals: getAppPortalConfiguration({ t }), widgets: getWidgetsConfiguration({ t }), scripts: { head: null, body: null }, }; const appConfiguration = await cachified({ key: `appConfiguration`, ttl: 1000 * 60 * 60 * 24, // 1 day getFreshValue: async () => db.appConfiguration.findFirst(), }); if (!appConfiguration) { return conf; } conf.app.name = appConfiguration?.name ?? ""; // conf.app.url = appConfiguration?.url ?? ""; conf.app.theme = appConfiguration?.theme ?? defaultTheme; conf.email.provider = getTransactionalEmailProvider(appConfiguration?.emailProvider, defaultEmailConfig.provider); conf.email.fromEmail = appConfiguration?.emailFromEmail ?? defaultEmailConfig.fromEmail; conf.email.fromName = appConfiguration?.emailFromName ?? defaultEmailConfig.fromName; conf.email.supportEmail = appConfiguration?.emailSupportEmail ?? defaultEmailConfig.supportEmail; conf.auth.requireEmailVerification = appConfiguration?.authRequireEmailVerification; conf.auth.requireOrganization = appConfiguration?.authRequireOrganization; conf.auth.requireName = appConfiguration?.authRequireName; // Captcha (mCaptcha) is env-driven (MCAPTCHA_*), not stored per-tenant in the DB. // `conf.auth.captcha` is already populated from webConfig in the default builder. conf.analytics.enabled = appConfiguration?.analyticsEnabled; conf.analytics.simpleAnalytics = appConfiguration?.analyticsSimpleAnalytics; conf.analytics.plausibleAnalytics = appConfiguration?.analyticsPlausibleAnalytics; conf.subscription.required = appConfiguration?.subscriptionRequired; conf.subscription.allowSubscribeBeforeSignUp = appConfiguration?.subscriptionAllowSubscribeBeforeSignUp; conf.subscription.allowSignUpBeforeSubscribe = appConfiguration?.subscriptionAllowSignUpBeforeSubscribe; conf.cookies.enabled = appConfiguration?.cookiesEnabled; conf.metrics.enabled = appConfiguration?.metricsEnabled; conf.metrics.logToConsole = appConfiguration?.metricsLogToConsole; conf.metrics.saveToDatabase = appConfiguration?.metricsSaveToDatabase; conf.metrics.ignoreUrls = appConfiguration?.metricsIgnoreUrls?.split(",") ?? []; conf.branding.logo = appConfiguration?.brandingLogo ?? undefined; conf.branding.logoDarkMode = appConfiguration?.brandingLogoDarkMode ?? undefined; conf.branding.icon = appConfiguration?.brandingIcon ?? undefined; conf.branding.iconDarkMode = appConfiguration?.brandingIconDarkMode ?? undefined; conf.branding.favicon = appConfiguration?.brandingFavicon ?? undefined; conf.scripts = { head: appConfiguration?.headScripts || null, body: appConfiguration?.bodyScripts || null, }; return conf; } export async function getOrCreateAppConfiguration({ request }: { request: Request }) { let settings = await db.appConfiguration.findFirst(); if (!settings) { const conf = await getAppConfiguration({ request }); settings = await db.appConfiguration .create({ data: { name: conf.app.name, url: conf.app.url, authRequireEmailVerification: conf.auth.requireEmailVerification, authRequireOrganization: conf.auth.requireOrganization, authRequireName: conf.auth.requireName, analyticsEnabled: conf.analytics.enabled, analyticsSimpleAnalytics: conf.analytics.simpleAnalytics, analyticsPlausibleAnalytics: conf.analytics.plausibleAnalytics, subscriptionRequired: conf.subscription.required, subscriptionAllowSubscribeBeforeSignUp: conf.subscription.allowSubscribeBeforeSignUp, subscriptionAllowSignUpBeforeSubscribe: conf.subscription.allowSignUpBeforeSubscribe, cookiesEnabled: conf.cookies.enabled, metricsEnabled: conf.metrics.enabled, metricsLogToConsole: conf.metrics.logToConsole, metricsSaveToDatabase: conf.metrics.saveToDatabase, metricsIgnoreUrls: conf.metrics.ignoreUrls.join(","), brandingLogo: conf.branding.logo, brandingLogoDarkMode: conf.branding.logoDarkMode, brandingIcon: conf.branding.icon, brandingIconDarkMode: conf.branding.iconDarkMode, brandingFavicon: conf.branding.favicon, }, }) .then((item) => { clearCacheKey(`appConfiguration`); return item; }); } return settings; } function getTransactionalEmailProvider( provider: string | null | undefined, fallback: AppConfiguration["email"]["provider"] ): AppConfiguration["email"]["provider"] { if (provider === "mailjet") { return provider; } return fallback; } export async function updateAppConfiguration(data: Prisma.AppConfigurationUpdateInput) { return await db.appConfiguration .updateMany({ data, }) .then((item) => { clearCacheKey(`appConfiguration`); return item; }); } export async function deleteAppConfiguration() { return await db.appConfiguration.deleteMany({ where: {} }).then((item) => { clearCacheKey(`appConfiguration`); return item; }); }