import { getDb } from '../db/client.js' import { ExtensionTimeoutAction } from '../constants.js' const sql = getDb() export const getAnonymityConfig = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'anonymity'` return { anonymity_enabled: row?.value?.enabled ?? false } } export const setAnonymityConfig = async (enabled) => { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('anonymity', ${sql.json({ enabled })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` return { anonymity_enabled: enabled } } export const getMaxCustomersPerMitra = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'max_customers_per_mitra'` return { max_customers_per_mitra: row?.value?.value ?? 3 } } export const setMaxCustomersPerMitra = async (value) => { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('max_customers_per_mitra', ${sql.json({ value })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` // Capacity changed → drop cached availability snapshot. // Imported lazily to avoid a circular import (mitra-status.service uses config). const { invalidateAvailabilityCache } = await import('./mitra-status.service.js') invalidateAvailabilityCache() return { max_customers_per_mitra: value } } // --- Phase 4: First-session discount config (back-compat shim) --- // // The canonical source of truth for the first-session discount lives in the // `pricing_promotions` table (eligibility = 'first_session'). The CC settings // page still calls `/internal/config/free-trial`, which exposes a slim // {enabled, duration_minutes} view — kept as a back-compat shim until the CC // UI is migrated to the richer /internal/config/first-session-discount handler. // Reads and writes go directly against `pricing_promotions` so operator edits // stay in sync with the customer-facing pricing payload. // // The legacy `first_session_discount_*` keys in `app_config` were retired in // Stage 5 (deleted by migrate.js) — do NOT reintroduce them. export const getFreeTrialConfig = async () => { const [row] = await sql` SELECT enabled, duration_minutes FROM pricing_promotions WHERE eligibility = 'first_session' ` return { enabled: row?.enabled ?? true, duration_minutes: row?.duration_minutes ?? 12, } } export const setFreeTrialConfig = async ({ enabled, duration_minutes }) => { // Build a sparse UPDATE so undefined fields are left alone (matches the prior // semantics where missing patch fields were no-ops). Use COALESCE on each // column with the sentinel-when-undefined pattern; postgres.js parameterizes // null/undefined identically, so we branch on which fields the caller sent. if (enabled === undefined && duration_minutes === undefined) { return getFreeTrialConfig() } await sql` UPDATE pricing_promotions SET enabled = ${enabled === undefined ? sql`enabled` : enabled}, duration_minutes = ${duration_minutes === undefined ? sql`duration_minutes` : duration_minutes}, updated_at = NOW() WHERE eligibility = 'first_session' ` return getFreeTrialConfig() } // --- Phase 4: Support handles --- export const getSupportHandles = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'support_handles_json'` // Stored shape: { wa: {...}, telegram: {...} }. Fall back to a safe empty payload // so the client renders an empty Tanya Admin sheet rather than crashing. return row?.value ?? { wa: { label: 'WhatsApp', deeplink: '' }, telegram: { label: 'Telegram', deeplink: '' }, } } export const setSupportHandles = async ({ wa, telegram }) => { const current = await getSupportHandles() const next = { wa: { ...current.wa, ...(wa || {}) }, telegram: { ...current.telegram, ...(telegram || {}) }, } await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('support_handles_json', ${sql.json(next)}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` return next } export const getExtensionTimeoutConfig = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_timeout_seconds'` // Default 10s pairs with the auto-approve-on-timeout flow; raise this if you change the policy to auto-reject. return { extension_timeout_seconds: row?.value?.value ?? 10 } } export const setExtensionTimeoutConfig = async (seconds) => { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('extension_timeout_seconds', ${sql.json({ value: seconds })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` return { extension_timeout_seconds: seconds } } export const getEarlyEndConfig = async () => { const [mitraRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_mitra_enabled'` const [customerRow] = await sql`SELECT value FROM app_config WHERE key = 'early_end_customer_enabled'` return { mitra_enabled: mitraRow?.value?.value ?? false, customer_enabled: customerRow?.value?.value ?? false, } } // --- Mitra reachability config --- // // Two separate concerns, deliberately decoupled: // - heartbeat_cadence_seconds: how often the mitra app sends a heartbeat. // Fixed per backend deployment via the MITRA_HEARTBEAT_CADENCE_SECONDS // env (default 30). The mitra app reads this from /api/mitra/status and // uses it directly as its Timer.periodic interval. // - stale_after_seconds: how long the backend tolerates silence before // marking a mitra offline. DB-stored, CC-tunable. Must be >= the // heartbeat cadence (CC PATCH validates this). // // `require_ping` stays as the master switch — when false, the auto-offline // sweep is skipped entirely and mitras stay online forever once they toggle. export const getMitraHeartbeatCadenceSeconds = () => { const raw = process.env.MITRA_HEARTBEAT_CADENCE_SECONDS if (!raw || raw.trim() === '') return 30 const parsed = Number.parseInt(raw, 10) return Number.isFinite(parsed) && parsed >= 5 ? parsed : 30 } export const getMitraPingConfig = async () => { const [requireRow] = await sql`SELECT value FROM app_config WHERE key = 'require_mitra_ping'` const [staleRow] = await sql`SELECT value FROM app_config WHERE key = 'mitra_stale_after_seconds'` return { require_ping: requireRow?.value?.value ?? true, stale_after_seconds: staleRow?.value?.value ?? 45, heartbeat_cadence_seconds: getMitraHeartbeatCadenceSeconds(), } } export const setMitraPingConfig = async ({ require_ping, stale_after_seconds }) => { if (require_ping !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('require_mitra_ping', ${sql.json({ value: require_ping })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } if (stale_after_seconds !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('mitra_stale_after_seconds', ${sql.json({ value: stale_after_seconds })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } return getMitraPingConfig() } export const setEarlyEndConfig = async ({ mitra_enabled, customer_enabled }) => { if (mitra_enabled !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('early_end_mitra_enabled', ${sql.json({ value: mitra_enabled })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } if (customer_enabled !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('early_end_customer_enabled', ${sql.json({ value: customer_enabled })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } return getEarlyEndConfig() } // --- Phase 3.3: Session Topic Sensitivity --- export const getSensitivityConfig = async () => { const [confirmRow] = await sql`SELECT value FROM app_config WHERE key = 'sensitive_flip_confirmation_enabled'` const [latchRow] = await sql`SELECT value FROM app_config WHERE key = 'sensitive_flag_one_way_latch'` return { flip_confirmation_enabled: confirmRow?.value?.value ?? true, one_way_latch: latchRow?.value?.value ?? false, } } export const setSensitivityConfig = async ({ flip_confirmation_enabled, one_way_latch }) => { if (flip_confirmation_enabled !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('sensitive_flip_confirmation_enabled', ${sql.json({ value: flip_confirmation_enabled })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } if (one_way_latch !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('sensitive_flag_one_way_latch', ${sql.json({ value: one_way_latch })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } return getSensitivityConfig() } // --- Phase 3.4: Self-Managed Auth --- export const getOtpRateLimits = async () => { const [phoneRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_max_per_phone_per_hour'` const [ipRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_max_per_ip_per_hour'` const [resendRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_resend_cooldown_seconds'` const [attemptsRow] = await sql`SELECT value FROM app_config WHERE key = 'otp_verify_max_attempts'` return { max_per_phone_per_hour: phoneRow?.value?.value ?? 3, max_per_ip_per_hour: ipRow?.value?.value ?? 10, resend_cooldown_seconds: resendRow?.value?.value ?? 60, verify_max_attempts: attemptsRow?.value?.value ?? 5, } } export const setOtpRateLimits = async ({ max_per_phone_per_hour, max_per_ip_per_hour, resend_cooldown_seconds, verify_max_attempts, }) => { const pairs = [ ['otp_max_per_phone_per_hour', max_per_phone_per_hour], ['otp_max_per_ip_per_hour', max_per_ip_per_hour], ['otp_resend_cooldown_seconds', resend_cooldown_seconds], ['otp_verify_max_attempts', verify_max_attempts], ] for (const [key, value] of pairs) { if (value === undefined) continue await sql` INSERT INTO app_config (key, value, updated_at) VALUES (${key}, ${sql.json({ value })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } return getOtpRateLimits() } export const getCcLoginLockoutConfig = async () => { const [attemptsRow] = await sql`SELECT value FROM app_config WHERE key = 'cc_login_max_attempts'` const [minutesRow] = await sql`SELECT value FROM app_config WHERE key = 'cc_login_lockout_minutes'` return { max_attempts: attemptsRow?.value?.value ?? 5, lockout_minutes: minutesRow?.value?.value ?? 15, } } export const setCcLoginLockoutConfig = async ({ max_attempts, lockout_minutes }) => { if (max_attempts !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('cc_login_max_attempts', ${sql.json({ value: max_attempts })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } if (lockout_minutes !== undefined) { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('cc_login_lockout_minutes', ${sql.json({ value: lockout_minutes })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` } return getCcLoginLockoutConfig() } // --- Paid Pairing Flow + Returning-Chat + Extension Flip --- export const getPaymentSessionTimeoutMinutes = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'payment_session_timeout_minutes'` return { payment_session_timeout_minutes: row?.value?.value ?? 20 } } export const setPaymentSessionTimeoutMinutes = async (value) => { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('payment_session_timeout_minutes', ${sql.json({ value })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` return { payment_session_timeout_minutes: value } } export const getReturningChatConfirmationTimeoutSeconds = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'returning_chat_confirmation_timeout_seconds'` return { returning_chat_confirmation_timeout_seconds: row?.value?.value ?? 20 } } export const setReturningChatConfirmationTimeoutSeconds = async (value) => { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('returning_chat_confirmation_timeout_seconds', ${sql.json({ value })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` return { returning_chat_confirmation_timeout_seconds: value } } export const getExtensionDefaultActionOnTimeout = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'extension_default_action_on_timeout'` return { extension_default_action_on_timeout: row?.value?.value ?? ExtensionTimeoutAction.AUTO_APPROVE } } export const setExtensionDefaultActionOnTimeout = async (value) => { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('extension_default_action_on_timeout', ${sql.json({ value })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` return { extension_default_action_on_timeout: value } } export const getPairingBlastTimeoutSeconds = async () => { const [row] = await sql`SELECT value FROM app_config WHERE key = 'pairing_blast_timeout_seconds'` return { pairing_blast_timeout_seconds: row?.value?.value ?? 60 } } export const setPairingBlastTimeoutSeconds = async (value) => { await sql` INSERT INTO app_config (key, value, updated_at) VALUES ('pairing_blast_timeout_seconds', ${sql.json({ value })}, NOW()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() ` return { pairing_blast_timeout_seconds: value } }