platform-settings.ts131 lines · main
1import { eq } from 'drizzle-orm';
2
3import { getDb } from '../db/client.js';
4import { platformSettings } from '../db/schema.js';
5import { 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
19const CACHE_TTL_MS = 60_000;
20
21interface CacheEntry {
22 value: unknown;
23 expiresAt: number;
24}
25
26const cache = new Map<string, CacheEntry>();
27
28export 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
50export 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
66export 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 */
75export 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 */
85export 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 */
102export 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
111export 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}