import { and, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { systemSettings } from '@/lib/db/schema'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { NotFoundError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; export async function listSettings(portId: string) { // Get port-specific settings const portSettings = await db .select() .from(systemSettings) .where(eq(systemSettings.portId, portId)) .orderBy(systemSettings.key); // Get global settings (portId is null) const globalSettings = await db .select() .from(systemSettings) .where(isNull(systemSettings.portId)) .orderBy(systemSettings.key); return { portSettings, globalSettings }; } export async function getSetting(key: string, portId: string) { // Try port-specific first, fall back to global const setting = await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)), }); if (setting) return setting; const global = await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), isNull(systemSettings.portId)), }); return global ?? null; } export async function upsertSetting(key: string, value: unknown, portId: string, meta: AuditMeta) { // Read existing first for the audit-log diff (before/after). The actual // write goes through onConflictDoUpdate so two concurrent calls can't // both observe `existing=null` and both INSERT - the (key, port_id) // unique index now treats NULLs as equal (migration 0047). const existing = await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)), }); await db .insert(systemSettings) .values({ key, value: value as Record, portId, updatedBy: meta.userId, }) .onConflictDoUpdate({ target: [systemSettings.key, systemSettings.portId], set: { value: value as Record, updatedBy: meta.userId, updatedAt: new Date(), }, }); // H-06: keys ending with `_encrypted` carry AES-GCM ciphertext that's only // useful with EMAIL_CREDENTIAL_KEY. Recording the ciphertext verbatim in // audit_logs.new_value would turn the audit log (readable by any admin // with `admin.view_audit_log`) into a credential bundle - if the // encryption key is ever rotated/leaked the history exfils every // configured password. Mask the value but keep the audit trail itself // (who toggled what, when). const isEncryptedKey = key.endsWith('_encrypted'); const auditOldValue = existing ? { value: isEncryptedKey ? '[redacted]' : existing.value } : undefined; const auditNewValue = { value: isEncryptedKey ? '[redacted]' : value }; void createAuditLog({ userId: meta.userId, portId, action: existing ? 'update' : 'create', entityType: 'setting', entityId: key, oldValue: auditOldValue, newValue: auditNewValue, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'system:alert', { alertType: 'setting:updated', message: `Setting "${key}" updated`, severity: 'info', }); return { key, value, portId }; } export async function deleteSetting(key: string, portId: string, meta: AuditMeta) { const existing = await db.query.systemSettings.findFirst({ where: and(eq(systemSettings.key, key), eq(systemSettings.portId, portId)), }); if (!existing) throw new NotFoundError('Setting'); await db .delete(systemSettings) .where(and(eq(systemSettings.key, key), eq(systemSettings.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'setting', entityId: key, oldValue: { value: key.endsWith('_encrypted') ? '[redacted]' : existing.value }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); }