platform-settings.ts131 lines · main
| 1 | import { eq } from 'drizzle-orm'; |
| 2 | |
| 3 | import { getDb } from '../db/client.js'; |
| 4 | import { platformSettings } from '../db/schema.js'; |
| 5 | import { env } from '../env.js'; |
| 6 | |
| 7 | /** |
| 8 | * Dashboard-controllable platform flags. Today: `openSignups`. Future |
| 9 | * keys land here too. |
| 10 | * |
| 11 | * Reads are cached in-process for `CACHE_TTL_MS` so the auth hot path |
| 12 | * (every signup attempt) doesn't roundtrip to the DB per request. |
| 13 | * Writes invalidate the local cache; on multi-instance deployments the |
| 14 | * peer instance picks up the change within the TTL window — fine for |
| 15 | * flag-flip semantics where eventual consistency on the order of a |
| 16 | * minute is acceptable. |
| 17 | */ |
| 18 | |
| 19 | const CACHE_TTL_MS = 60_000; |
| 20 | |
| 21 | interface CacheEntry { |
| 22 | value: unknown; |
| 23 | expiresAt: number; |
| 24 | } |
| 25 | |
| 26 | const cache = new Map<string, CacheEntry>(); |
| 27 | |
| 28 | export async function getPlatformSetting<T>(key: string, fallback: T): Promise<T> { |
| 29 | const now = Date.now(); |
| 30 | const hit = cache.get(key); |
| 31 | if (hit && hit.expiresAt > now) return hit.value as T; |
| 32 | |
| 33 | const db = getDb(); |
| 34 | const rows = await db |
| 35 | .select({ value: platformSettings.value }) |
| 36 | .from(platformSettings) |
| 37 | .where(eq(platformSettings.key, key)) |
| 38 | .limit(1); |
| 39 | const value = rows[0]?.value; |
| 40 | if (value === undefined) { |
| 41 | // Cache the fallback so repeated misses don't hammer the DB. The |
| 42 | // setter invalidates this on every write. |
| 43 | cache.set(key, { value: fallback, expiresAt: now + CACHE_TTL_MS }); |
| 44 | return fallback; |
| 45 | } |
| 46 | cache.set(key, { value, expiresAt: now + CACHE_TTL_MS }); |
| 47 | return value as T; |
| 48 | } |
| 49 | |
| 50 | export async function setPlatformSetting( |
| 51 | key: string, |
| 52 | value: unknown, |
| 53 | updatedBy: string | null, |
| 54 | ): Promise<void> { |
| 55 | const db = getDb(); |
| 56 | await db |
| 57 | .insert(platformSettings) |
| 58 | .values({ key, value, updatedBy }) |
| 59 | .onConflictDoUpdate({ |
| 60 | target: platformSettings.key, |
| 61 | set: { value, updatedAt: new Date(), updatedBy }, |
| 62 | }); |
| 63 | cache.delete(key); |
| 64 | } |
| 65 | |
| 66 | export function invalidatePlatformSettingCache(key: string): void { |
| 67 | cache.delete(key); |
| 68 | } |
| 69 | |
| 70 | /** |
| 71 | * Effective open-signups flag. DB takes precedence over the env var; |
| 72 | * the env stays as the bootstrap default before the first dashboard |
| 73 | * flip ever lands a row. |
| 74 | */ |
| 75 | export async function getOpenSignupsFlag(): Promise<boolean> { |
| 76 | const dbValue = await getPlatformSetting<unknown>('openSignups', undefined); |
| 77 | if (typeof dbValue === 'boolean') return dbValue; |
| 78 | return env.BRIVEN_OPEN_SIGNUPS; |
| 79 | } |
| 80 | |
| 81 | /** |
| 82 | * Scheduled maintenance window, stored under `platform_settings.maintenanceWindow`. |
| 83 | * All timestamps are ISO date-time strings (or null when unset). |
| 84 | */ |
| 85 | export interface MaintenanceWindow { |
| 86 | startsAt: string | null; |
| 87 | endsAt: string | null; |
| 88 | message: string | null; |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Effective maintenance state, derived from two platform_settings keys: |
| 93 | * - `maintenanceMode` (bool) — manual immediate override (back-compat). |
| 94 | * - `maintenanceWindow` (json) — { startsAt, endsAt, message }. |
| 95 | * |
| 96 | * Times are compared as Date.parse of ISO strings (pure JS date math — no |
| 97 | * DB date params, per the Bun sql`` gotcha). |
| 98 | * - scheduled = startsAt != null && endsAt != null |
| 99 | * - active = manualOverride OR (scheduled && now >= startsAt && now < endsAt) |
| 100 | * - upcoming = scheduled && now < startsAt (pre-announcement banner) |
| 101 | */ |
| 102 | export interface MaintenanceState { |
| 103 | active: boolean; |
| 104 | scheduled: boolean; |
| 105 | upcoming: boolean; |
| 106 | startsAt: string | null; |
| 107 | endsAt: string | null; |
| 108 | message: string | null; |
| 109 | } |
| 110 | |
| 111 | export async function getMaintenanceState(): Promise<MaintenanceState> { |
| 112 | const [manualOverride, window] = await Promise.all([ |
| 113 | getPlatformSetting<boolean>('maintenanceMode', false), |
| 114 | getPlatformSetting<MaintenanceWindow | null>('maintenanceWindow', null), |
| 115 | ]); |
| 116 | |
| 117 | const startsAt = window?.startsAt ?? null; |
| 118 | const endsAt = window?.endsAt ?? null; |
| 119 | const message = window?.message ?? null; |
| 120 | |
| 121 | const scheduled = startsAt !== null && endsAt !== null; |
| 122 | const now = Date.now(); |
| 123 | const startMs = startsAt !== null ? Date.parse(startsAt) : NaN; |
| 124 | const endMs = endsAt !== null ? Date.parse(endsAt) : NaN; |
| 125 | |
| 126 | const inWindow = scheduled && now >= startMs && now < endMs; |
| 127 | const active = manualOverride === true || inWindow; |
| 128 | const upcoming = scheduled && now < startMs; |
| 129 | |
| 130 | return { active, scheduled, upcoming, startsAt, endsAt, message }; |
| 131 | } |