Splits the single mitra_ping_interval_seconds config (which conflated
"how often the app pings" with "how long until offline" through a
hidden ×3 multiplier) into two orthogonal knobs:
- mitra_stale_after_seconds (CC-tunable, app_config DB row): the
operator-facing offline threshold. What you set is what you get —
no multiplier. Default 45s (preserves today's effective grace at
the legacy 15s ping default).
- MITRA_HEARTBEAT_CADENCE_SECONDS (env var, default 30s): how often
the mitra app sends a heartbeat. Backend-fixed per deployment;
surfaced to the mitra app via /api/mitra/status.
Backend:
- config.service: getMitraPingConfig returns the new tuple
{require_ping, stale_after_seconds, heartbeat_cadence_seconds}.
Env parser handles blank/non-numeric → 30 fallback.
- mitra-status.service::autoOfflineStaleMitras drops the *3 and uses
stale_after_seconds directly.
- mitra-status.service::getStatus returns heartbeat_cadence_seconds
instead of ping_interval_seconds.
- /internal/config/mitra-ping PATCH validates
stale_after_seconds >= cadence, returns 422 with a clear message
("stale_after_seconds must be a number >= heartbeat cadence (30s)").
- migrate.js: adds mitra_stale_after_seconds default 45. The old
mitra_ping_interval_seconds key is left in place (vestigial) —
no live code reads it; safe to drop after one release.
Mitra app:
- status_notifier reads heartbeat_cadence_seconds, uses it directly
as the Timer.periodic interval. Defaults to 30s if missing (older
backend safety).
Control center:
- SettingsPage: renames "Interval Ping" → "Ambang offline", input
min={heartbeat_cadence_seconds}, shows the cadence as a read-only
value with explanation that it's env-controlled.
Verified end-to-end on dev backend:
- GET /api/mitra/status returns {…, heartbeat_cadence_seconds: 30}
- GET /internal/config/mitra-ping returns {require_ping,
stale_after_seconds: 45, heartbeat_cadence_seconds: 30}
- PATCH with stale_after_seconds=20 → 422 with cadence message
- PATCH with stale_after_seconds=120 → 200, persisted
- Env override (=60, blank, "foo") parses correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
349 lines
14 KiB
JavaScript
349 lines
14 KiB
JavaScript
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 }
|
|
}
|