/** * EncryptID Database Layer — PostgreSQL * * Replaces in-memory Maps with persistent PostgreSQL storage. * Uses the 'postgres' npm package (lightweight, no native deps with Bun). */ import postgres from 'postgres'; import { readFileSync } from 'fs'; import { join } from 'path'; // ============================================================================ // CONNECTION // ============================================================================ const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { throw new Error('DATABASE_URL environment variable is required'); } const sql = postgres(DATABASE_URL, { max: 10, idle_timeout: 20, connect_timeout: 10, }); // ============================================================================ // TYPES // ============================================================================ export interface StoredCredential { credentialId: string; publicKey: string; userId: string; username: string; counter: number; createdAt: number; lastUsed?: number; transports?: string[]; rpId?: string; } export interface StoredChallenge { challenge: string; userId?: string; type: 'registration' | 'authentication' | 'device_registration'; createdAt: number; expiresAt: number; } // ============================================================================ // INITIALIZATION // ============================================================================ export async function initDatabase(): Promise { const schema = readFileSync(join(import.meta.dir, 'schema.sql'), 'utf-8'); await sql.unsafe(schema); console.log('EncryptID: Database initialized'); // Clean expired challenges on startup await cleanExpiredChallenges(); } // ============================================================================ // USER OPERATIONS // ============================================================================ export async function createUser(id: string, username: string, displayName?: string, did?: string): Promise { await sql` INSERT INTO users (id, username, display_name, did) VALUES (${id}, ${username}, ${displayName || username}, ${did || null}) ON CONFLICT (id) DO NOTHING `; } export async function getUserByUsername(username: string) { const [user] = await sql`SELECT * FROM users WHERE username = ${username}`; return user || null; } // ============================================================================ // CREDENTIAL OPERATIONS // ============================================================================ export async function storeCredential(cred: StoredCredential): Promise { // Ensure user exists first await createUser(cred.userId, cred.username); await sql` INSERT INTO credentials (credential_id, user_id, public_key, counter, transports, created_at, rp_id) VALUES ( ${cred.credentialId}, ${cred.userId}, ${cred.publicKey}, ${cred.counter}, ${cred.transports || null}, ${new Date(cred.createdAt)}, ${cred.rpId || 'rspace.online'} ) `; } export async function getCredential(credentialId: string): Promise { const rows = await sql` SELECT c.credential_id, c.public_key, c.user_id, c.counter, c.transports, c.created_at, c.last_used, u.username FROM credentials c JOIN users u ON c.user_id = u.id WHERE c.credential_id = ${credentialId} `; if (rows.length === 0) return null; const row = rows[0]; return { credentialId: row.credential_id, publicKey: row.public_key, userId: row.user_id, username: row.username, counter: row.counter, createdAt: new Date(row.created_at).getTime(), lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined, transports: row.transports, }; } export async function updateCredentialUsage(credentialId: string, newCounter: number): Promise { await sql` UPDATE credentials SET counter = ${newCounter}, last_used = NOW() WHERE credential_id = ${credentialId} `; } export async function getUserCredentials(userId: string): Promise { const rows = await sql` SELECT c.credential_id, c.public_key, c.user_id, c.counter, c.transports, c.created_at, c.last_used, u.username FROM credentials c JOIN users u ON c.user_id = u.id WHERE c.user_id = ${userId} `; return rows.map(row => ({ credentialId: row.credential_id, publicKey: row.public_key, userId: row.user_id, username: row.username, counter: row.counter, createdAt: new Date(row.created_at).getTime(), lastUsed: row.last_used ? new Date(row.last_used).getTime() : undefined, transports: row.transports, })); } // ============================================================================ // CHALLENGE OPERATIONS // ============================================================================ export async function storeChallenge(ch: StoredChallenge): Promise { await sql` INSERT INTO challenges (challenge, user_id, type, created_at, expires_at) VALUES ( ${ch.challenge}, ${ch.userId || null}, ${ch.type}, ${new Date(ch.createdAt)}, ${new Date(ch.expiresAt)} ) `; } export async function getChallenge(challenge: string): Promise { const rows = await sql` SELECT * FROM challenges WHERE challenge = ${challenge} `; if (rows.length === 0) return null; const row = rows[0]; return { challenge: row.challenge, userId: row.user_id || undefined, type: row.type as 'registration' | 'authentication', createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), }; } export async function deleteChallenge(challenge: string): Promise { await sql`DELETE FROM challenges WHERE challenge = ${challenge}`; } export async function cleanExpiredChallenges(): Promise { const result = await sql`DELETE FROM challenges WHERE expires_at < NOW()`; return result.count; } // ============================================================================ // USER EMAIL OPERATIONS // ============================================================================ export async function setUserEmail(userId: string, email: string): Promise { await sql`UPDATE users SET email = ${email} WHERE id = ${userId}`; } export async function getUserByEmail(email: string) { const [user] = await sql`SELECT * FROM users WHERE email = ${email}`; return user || null; } export async function getUserById(userId: string) { const [user] = await sql`SELECT * FROM users WHERE id = ${userId}`; return user || null; } // ============================================================================ // RECOVERY TOKEN OPERATIONS // ============================================================================ export interface StoredRecoveryToken { token: string; userId: string; type: 'email_verify' | 'account_recovery'; createdAt: number; expiresAt: number; used: boolean; } export async function storeRecoveryToken(rt: StoredRecoveryToken): Promise { await sql` INSERT INTO recovery_tokens (token, user_id, type, created_at, expires_at, used) VALUES ( ${rt.token}, ${rt.userId}, ${rt.type}, ${new Date(rt.createdAt)}, ${new Date(rt.expiresAt)}, ${rt.used} ) `; } export async function getRecoveryToken(token: string): Promise { const rows = await sql`SELECT * FROM recovery_tokens WHERE token = ${token}`; if (rows.length === 0) return null; const row = rows[0]; return { token: row.token, userId: row.user_id, type: row.type as 'email_verify' | 'account_recovery', createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), used: row.used, }; } export async function markRecoveryTokenUsed(token: string): Promise { await sql`UPDATE recovery_tokens SET used = TRUE WHERE token = ${token}`; } export async function cleanExpiredRecoveryTokens(): Promise { const result = await sql`DELETE FROM recovery_tokens WHERE expires_at < NOW()`; return result.count; } // ============================================================================ // SPACE MEMBERSHIP // ============================================================================ export interface StoredSpaceMember { spaceSlug: string; userDID: string; role: string; joinedAt: number; grantedBy?: string; } export async function listSpacesForUser(userDID: string): Promise { const rows = await sql` SELECT * FROM space_members WHERE user_did = ${userDID} ORDER BY joined_at DESC `; return rows.map((row) => ({ spaceSlug: row.space_slug, userDID: row.user_did, role: row.role, joinedAt: new Date(row.joined_at).getTime(), grantedBy: row.granted_by || undefined, })); } export async function getSpaceMember( spaceSlug: string, userDID: string, ): Promise { const rows = await sql` SELECT * FROM space_members WHERE space_slug = ${spaceSlug} AND user_did = ${userDID} `; if (rows.length === 0) return null; const row = rows[0]; return { spaceSlug: row.space_slug, userDID: row.user_did, role: row.role, joinedAt: new Date(row.joined_at).getTime(), grantedBy: row.granted_by || undefined, }; } export async function listSpaceMembers( spaceSlug: string, ): Promise { const rows = await sql` SELECT * FROM space_members WHERE space_slug = ${spaceSlug} ORDER BY joined_at ASC `; return rows.map((row) => ({ spaceSlug: row.space_slug, userDID: row.user_did, role: row.role, joinedAt: new Date(row.joined_at).getTime(), grantedBy: row.granted_by || undefined, })); } export async function upsertSpaceMember( spaceSlug: string, userDID: string, role: string, grantedBy?: string, ): Promise { const rows = await sql` INSERT INTO space_members (space_slug, user_did, role, granted_by) VALUES (${spaceSlug}, ${userDID}, ${role}, ${grantedBy ?? null}) ON CONFLICT (space_slug, user_did) DO UPDATE SET role = ${role}, granted_by = ${grantedBy ?? null} RETURNING * `; const row = rows[0]; return { spaceSlug: row.space_slug, userDID: row.user_did, role: row.role, joinedAt: new Date(row.joined_at).getTime(), grantedBy: row.granted_by || undefined, }; } export async function removeSpaceMember( spaceSlug: string, userDID: string, ): Promise { const result = await sql` DELETE FROM space_members WHERE space_slug = ${spaceSlug} AND user_did = ${userDID} `; return result.count > 0; } // ============================================================================ // GUARDIAN OPERATIONS // ============================================================================ export interface StoredGuardian { id: string; userId: string; name: string; email: string | null; guardianUserId: string | null; status: 'pending' | 'accepted' | 'revoked'; inviteToken: string | null; inviteExpiresAt: number | null; acceptedAt: number | null; createdAt: number; } function rowToGuardian(row: any): StoredGuardian { return { id: row.id, userId: row.user_id, name: row.name, email: row.email || null, guardianUserId: row.guardian_user_id || null, status: row.status, inviteToken: row.invite_token || null, inviteExpiresAt: row.invite_expires_at ? new Date(row.invite_expires_at).getTime() : null, acceptedAt: row.accepted_at ? new Date(row.accepted_at).getTime() : null, createdAt: new Date(row.created_at).getTime(), }; } export async function addGuardian( id: string, userId: string, name: string, email: string | null, inviteToken: string, inviteExpiresAt: number, ): Promise { const rows = await sql` INSERT INTO guardians (id, user_id, name, email, invite_token, invite_expires_at) VALUES (${id}, ${userId}, ${name}, ${email}, ${inviteToken}, ${new Date(inviteExpiresAt)}) RETURNING * `; return rowToGuardian(rows[0]); } export async function getGuardians(userId: string): Promise { const rows = await sql` SELECT * FROM guardians WHERE user_id = ${userId} AND status != 'revoked' ORDER BY created_at ASC `; return rows.map(rowToGuardian); } export async function getGuardianByInviteToken(token: string): Promise { const rows = await sql`SELECT * FROM guardians WHERE invite_token = ${token}`; if (rows.length === 0) return null; return rowToGuardian(rows[0]); } export async function acceptGuardianInvite(guardianId: string, guardianUserId: string): Promise { await sql` UPDATE guardians SET status = 'accepted', guardian_user_id = ${guardianUserId}, accepted_at = NOW(), invite_token = NULL WHERE id = ${guardianId} `; } export async function removeGuardian(guardianId: string, userId: string): Promise { const result = await sql` UPDATE guardians SET status = 'revoked' WHERE id = ${guardianId} AND user_id = ${userId} `; return result.count > 0; } export async function getGuardianById(guardianId: string): Promise { const rows = await sql`SELECT * FROM guardians WHERE id = ${guardianId}`; if (rows.length === 0) return null; return rowToGuardian(rows[0]); } export async function getGuardianships(guardianUserId: string): Promise { const rows = await sql` SELECT * FROM guardians WHERE guardian_user_id = ${guardianUserId} AND status = 'accepted' ORDER BY created_at ASC `; return rows.map(rowToGuardian); } // ============================================================================ // RECOVERY REQUEST OPERATIONS // ============================================================================ export interface StoredRecoveryRequest { id: string; userId: string; status: string; threshold: number; approvalCount: number; initiatedAt: number; expiresAt: number; completedAt: number | null; } function rowToRecoveryRequest(row: any): StoredRecoveryRequest { return { id: row.id, userId: row.user_id, status: row.status, threshold: row.threshold, approvalCount: row.approval_count, initiatedAt: new Date(row.initiated_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), completedAt: row.completed_at ? new Date(row.completed_at).getTime() : null, }; } export async function createRecoveryRequest( id: string, userId: string, threshold: number, expiresAt: number, ): Promise { const rows = await sql` INSERT INTO recovery_requests (id, user_id, threshold, expires_at) VALUES (${id}, ${userId}, ${threshold}, ${new Date(expiresAt)}) RETURNING * `; return rowToRecoveryRequest(rows[0]); } export async function getRecoveryRequest(requestId: string): Promise { const rows = await sql`SELECT * FROM recovery_requests WHERE id = ${requestId}`; if (rows.length === 0) return null; return rowToRecoveryRequest(rows[0]); } export async function getActiveRecoveryRequest(userId: string): Promise { const rows = await sql` SELECT * FROM recovery_requests WHERE user_id = ${userId} AND status = 'pending' AND expires_at > NOW() ORDER BY initiated_at DESC LIMIT 1 `; if (rows.length === 0) return null; return rowToRecoveryRequest(rows[0]); } export async function createRecoveryApproval( requestId: string, guardianId: string, approvalToken: string, ): Promise { await sql` INSERT INTO recovery_approvals (request_id, guardian_id, approval_token) VALUES (${requestId}, ${guardianId}, ${approvalToken}) ON CONFLICT DO NOTHING `; } export async function approveRecoveryByToken(approvalToken: string): Promise<{ requestId: string; guardianId: string } | null> { // Find the approval const rows = await sql` SELECT * FROM recovery_approvals WHERE approval_token = ${approvalToken} AND approved_at IS NULL `; if (rows.length === 0) return null; const row = rows[0]; // Mark approved await sql` UPDATE recovery_approvals SET approved_at = NOW(), approval_token = NULL WHERE request_id = ${row.request_id} AND guardian_id = ${row.guardian_id} `; // Increment approval count on request await sql` UPDATE recovery_requests SET approval_count = approval_count + 1 WHERE id = ${row.request_id} `; return { requestId: row.request_id, guardianId: row.guardian_id }; } export async function updateRecoveryRequestStatus(requestId: string, status: string): Promise { const completedAt = status === 'completed' ? sql`NOW()` : null; await sql` UPDATE recovery_requests SET status = ${status}, completed_at = ${completedAt} WHERE id = ${requestId} `; } export async function getRecoveryApprovals(requestId: string): Promise> { const rows = await sql` SELECT guardian_id, approved_at FROM recovery_approvals WHERE request_id = ${requestId} `; return rows.map(r => ({ guardianId: r.guardian_id, approvedAt: r.approved_at ? new Date(r.approved_at).getTime() : null, })); } // ============================================================================ // DEVICE LINK OPERATIONS // ============================================================================ export async function createDeviceLink(token: string, userId: string, expiresAt: number): Promise { await sql` INSERT INTO device_links (token, user_id, expires_at) VALUES (${token}, ${userId}, ${new Date(expiresAt)}) `; } export async function getDeviceLink(token: string): Promise<{ userId: string; expiresAt: number; used: boolean } | null> { const rows = await sql`SELECT * FROM device_links WHERE token = ${token}`; if (rows.length === 0) return null; return { userId: rows[0].user_id, expiresAt: new Date(rows[0].expires_at).getTime(), used: rows[0].used, }; } export async function markDeviceLinkUsed(token: string): Promise { await sql`UPDATE device_links SET used = TRUE WHERE token = ${token}`; } // ============================================================================ // USER PROFILE OPERATIONS // ============================================================================ export interface StoredUserProfile { userId: string; username: string; displayName: string | null; bio: string | null; avatarUrl: string | null; profileEmail: string | null; profileEmailIsRecovery: boolean; did: string | null; walletAddress: string | null; emailForwardEnabled: boolean; emailForwardMailcowId: string | null; createdAt: string; updatedAt: string; } function rowToProfile(row: any): StoredUserProfile { return { userId: row.id, username: row.username, displayName: row.display_name || null, bio: row.bio || null, avatarUrl: row.avatar_url || null, profileEmail: row.profile_email || null, profileEmailIsRecovery: row.profile_email_is_recovery || false, did: row.did || null, walletAddress: row.wallet_address || null, emailForwardEnabled: row.email_forward_enabled || false, emailForwardMailcowId: row.email_forward_mailcow_id || null, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), updatedAt: row.updated_at?.toISOString?.() || row.created_at?.toISOString?.() || new Date().toISOString(), }; } export async function getUserProfile(userId: string): Promise { const [row] = await sql`SELECT * FROM users WHERE id = ${userId}`; if (!row) return null; return rowToProfile(row); } export interface UserProfileUpdates { displayName?: string | null; bio?: string | null; avatarUrl?: string | null; profileEmail?: string | null; profileEmailIsRecovery?: boolean; walletAddress?: string | null; } export async function updateUserProfile(userId: string, updates: UserProfileUpdates): Promise { const sets: string[] = []; const values: any[] = []; if (updates.displayName !== undefined) { sets.push('display_name'); values.push(updates.displayName); } if (updates.bio !== undefined) { sets.push('bio'); values.push(updates.bio); } if (updates.avatarUrl !== undefined) { sets.push('avatar_url'); values.push(updates.avatarUrl); } if (updates.profileEmail !== undefined) { sets.push('profile_email'); values.push(updates.profileEmail); } if (updates.profileEmailIsRecovery !== undefined) { sets.push('profile_email_is_recovery'); values.push(updates.profileEmailIsRecovery); } if (updates.walletAddress !== undefined) { sets.push('wallet_address'); values.push(updates.walletAddress); } if (sets.length === 0) { return getUserProfile(userId); } // Build dynamic update — use tagged template for each field // postgres lib doesn't easily support dynamic column names, so we use unsafe for the SET clause const setClauses = sets.map((col, i) => `${col} = $${i + 2}`).join(', '); const params = [userId, ...values]; await sql.unsafe( `UPDATE users SET ${setClauses}, updated_at = NOW() WHERE id = $1`, params, ); return getUserProfile(userId); } // ============================================================================ // ENCRYPTED ADDRESS OPERATIONS // ============================================================================ export interface StoredEncryptedAddress { id: string; userId: string; ciphertext: string; iv: string; label: string; labelCustom: string | null; isDefault: boolean; createdAt: string; updatedAt: string; } function rowToAddress(row: any): StoredEncryptedAddress { return { id: row.id, userId: row.user_id, ciphertext: row.ciphertext, iv: row.iv, label: row.label, labelCustom: row.label_custom || null, isDefault: row.is_default || false, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), updatedAt: row.updated_at?.toISOString?.() || new Date(row.updated_at).toISOString(), }; } export async function getUserAddresses(userId: string): Promise { const rows = await sql` SELECT * FROM encrypted_addresses WHERE user_id = ${userId} ORDER BY is_default DESC, created_at ASC `; return rows.map(rowToAddress); } export async function getAddressById(id: string, userId: string): Promise { const [row] = await sql` SELECT * FROM encrypted_addresses WHERE id = ${id} AND user_id = ${userId} `; if (!row) return null; return rowToAddress(row); } export async function saveUserAddress( userId: string, addr: { id: string; ciphertext: string; iv: string; label: string; labelCustom?: string; isDefault: boolean }, ): Promise { // If setting as default, unset all others first if (addr.isDefault) { await sql`UPDATE encrypted_addresses SET is_default = FALSE WHERE user_id = ${userId}`; } const rows = await sql` INSERT INTO encrypted_addresses (id, user_id, ciphertext, iv, label, label_custom, is_default) VALUES (${addr.id}, ${userId}, ${addr.ciphertext}, ${addr.iv}, ${addr.label}, ${addr.labelCustom || null}, ${addr.isDefault}) ON CONFLICT (id, user_id) DO UPDATE SET ciphertext = ${addr.ciphertext}, iv = ${addr.iv}, label = ${addr.label}, label_custom = ${addr.labelCustom || null}, is_default = ${addr.isDefault}, updated_at = NOW() RETURNING * `; return rowToAddress(rows[0]); } export async function deleteUserAddress(id: string, userId: string): Promise { const result = await sql` DELETE FROM encrypted_addresses WHERE id = ${id} AND user_id = ${userId} `; return result.count > 0; } // ============================================================================ // EMAIL FORWARDING OPERATIONS // ============================================================================ export async function getEmailForwardStatus(userId: string): Promise<{ enabled: boolean; mailcowId: string | null; username: string; profileEmail: string | null; } | null> { const [row] = await sql` SELECT username, profile_email, email_forward_enabled, email_forward_mailcow_id FROM users WHERE id = ${userId} `; if (!row) return null; return { enabled: row.email_forward_enabled || false, mailcowId: row.email_forward_mailcow_id || null, username: row.username, profileEmail: row.profile_email || null, }; } export async function setEmailForward(userId: string, enabled: boolean, mailcowId: string | null): Promise { await sql` UPDATE users SET email_forward_enabled = ${enabled}, email_forward_mailcow_id = ${mailcowId}, updated_at = NOW() WHERE id = ${userId} `; } // ============================================================================ // ADMIN OPERATIONS // ============================================================================ export interface AdminUserInfo { userId: string; username: string; displayName: string | null; did: string | null; email: string | null; createdAt: string; credentialCount: number; spaceMembershipCount: number; } export async function listAllUsers(): Promise { const rows = await sql` SELECT u.id, u.username, u.display_name, u.did, u.email, u.created_at, (SELECT COUNT(*)::int FROM credentials c WHERE c.user_id = u.id) as credential_count, (SELECT COUNT(*)::int FROM space_members sm WHERE sm.user_did = u.did) as space_membership_count FROM users u ORDER BY u.created_at DESC `; return rows.map(row => ({ userId: row.id, username: row.username, displayName: row.display_name || null, did: row.did || null, email: row.email || null, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), credentialCount: Number(row.credential_count), spaceMembershipCount: Number(row.space_membership_count), })); } export async function deleteUser(userId: string): Promise { const user = await getUserById(userId); if (!user) return false; // Remove space memberships for this user's DID if (user.did) { await sql`DELETE FROM space_members WHERE user_did = ${user.did}`; } // Delete user (CASCADE handles credentials, recovery_tokens, guardians, etc.) const result = await sql`DELETE FROM users WHERE id = ${userId}`; return result.count > 0; } export async function deleteSpaceMembers(spaceSlug: string): Promise { const result = await sql`DELETE FROM space_members WHERE space_slug = ${spaceSlug}`; return result.count; } // ============================================================================ // HEALTH CHECK // ============================================================================ export async function checkDatabaseHealth(): Promise { try { await sql`SELECT 1`; return true; } catch { return false; } } // ============================================================================ // SPACE INVITE OPERATIONS // ============================================================================ export interface StoredSpaceInvite { id: string; spaceSlug: string; email: string | null; role: string; token: string; invitedBy: string; status: 'pending' | 'accepted' | 'expired' | 'revoked'; createdAt: number; expiresAt: number; acceptedAt: number | null; acceptedByDid: string | null; } function rowToInvite(row: any): StoredSpaceInvite { return { id: row.id, spaceSlug: row.space_slug, email: row.email || null, role: row.role, token: row.token, invitedBy: row.invited_by, status: row.status, createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), acceptedAt: row.accepted_at ? new Date(row.accepted_at).getTime() : null, acceptedByDid: row.accepted_by_did || null, }; } export async function createSpaceInvite( id: string, spaceSlug: string, token: string, invitedBy: string, role: string, expiresAt: number, email?: string, ): Promise { const rows = await sql` INSERT INTO space_invites (id, space_slug, email, role, token, invited_by, expires_at) VALUES (${id}, ${spaceSlug}, ${email || null}, ${role}, ${token}, ${invitedBy}, ${new Date(expiresAt)}) RETURNING * `; return rowToInvite(rows[0]); } export async function getSpaceInviteByToken(token: string): Promise { const rows = await sql`SELECT * FROM space_invites WHERE token = ${token}`; if (rows.length === 0) return null; return rowToInvite(rows[0]); } export async function listSpaceInvites(spaceSlug: string): Promise { const rows = await sql` SELECT * FROM space_invites WHERE space_slug = ${spaceSlug} ORDER BY created_at DESC `; return rows.map(rowToInvite); } export async function acceptSpaceInvite(token: string, acceptedByDid: string): Promise { const rows = await sql` UPDATE space_invites SET status = 'accepted', accepted_at = NOW(), accepted_by_did = ${acceptedByDid} WHERE token = ${token} AND status = 'pending' AND expires_at > NOW() RETURNING * `; if (rows.length === 0) return null; return rowToInvite(rows[0]); } export async function revokeSpaceInvite(id: string, spaceSlug: string): Promise { const result = await sql` UPDATE space_invites SET status = 'revoked' WHERE id = ${id} AND space_slug = ${spaceSlug} AND status = 'pending' `; return result.count > 0; } // ============================================================================ // NOTIFICATION OPERATIONS // ============================================================================ export interface StoredNotification { id: string; userDid: string; category: 'space' | 'module' | 'system' | 'social'; eventType: string; title: string; body: string | null; spaceSlug: string | null; moduleId: string | null; actionUrl: string | null; actorDid: string | null; actorUsername: string | null; metadata: Record; read: boolean; dismissed: boolean; deliveredWs: boolean; deliveredEmail: boolean; deliveredPush: boolean; createdAt: string; readAt: string | null; expiresAt: string | null; } function rowToNotification(row: any): StoredNotification { return { id: row.id, userDid: row.user_did, category: row.category, eventType: row.event_type, title: row.title, body: row.body || null, spaceSlug: row.space_slug || null, moduleId: row.module_id || null, actionUrl: row.action_url || null, actorDid: row.actor_did || null, actorUsername: row.actor_username || null, metadata: row.metadata || {}, read: row.read, dismissed: row.dismissed, deliveredWs: row.delivered_ws, deliveredEmail: row.delivered_email, deliveredPush: row.delivered_push, createdAt: row.created_at?.toISOString?.() || new Date(row.created_at).toISOString(), readAt: row.read_at ? (row.read_at?.toISOString?.() || new Date(row.read_at).toISOString()) : null, expiresAt: row.expires_at ? (row.expires_at?.toISOString?.() || new Date(row.expires_at).toISOString()) : null, }; } export async function createNotification(notif: { id: string; userDid: string; category: 'space' | 'module' | 'system' | 'social'; eventType: string; title: string; body?: string; spaceSlug?: string; moduleId?: string; actionUrl?: string; actorDid?: string; actorUsername?: string; metadata?: Record; expiresAt?: Date; }): Promise { const rows = await sql` INSERT INTO notifications (id, user_did, category, event_type, title, body, space_slug, module_id, action_url, actor_did, actor_username, metadata, expires_at) VALUES ( ${notif.id}, ${notif.userDid}, ${notif.category}, ${notif.eventType}, ${notif.title}, ${notif.body || null}, ${notif.spaceSlug || null}, ${notif.moduleId || null}, ${notif.actionUrl || null}, ${notif.actorDid || null}, ${notif.actorUsername || null}, ${JSON.stringify(notif.metadata || {})}, ${notif.expiresAt || null} ) RETURNING * `; return rowToNotification(rows[0]); } export async function getUserNotifications( userDid: string, opts: { unreadOnly?: boolean; limit?: number; offset?: number; category?: string } = {}, ): Promise { const limit = opts.limit || 50; const offset = opts.offset || 0; let rows; if (opts.unreadOnly && opts.category) { rows = await sql` SELECT * FROM notifications WHERE user_did = ${userDid} AND NOT dismissed AND NOT read AND category = ${opts.category} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } else if (opts.unreadOnly) { rows = await sql` SELECT * FROM notifications WHERE user_did = ${userDid} AND NOT dismissed AND NOT read ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } else if (opts.category) { rows = await sql` SELECT * FROM notifications WHERE user_did = ${userDid} AND NOT dismissed AND category = ${opts.category} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } else { rows = await sql` SELECT * FROM notifications WHERE user_did = ${userDid} AND NOT dismissed ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset} `; } return rows.map(rowToNotification); } export async function getUnreadCount(userDid: string): Promise { const [row] = await sql` SELECT COUNT(*)::int as count FROM notifications WHERE user_did = ${userDid} AND NOT read AND NOT dismissed `; return row.count; } export async function markNotificationRead(id: string, userDid: string): Promise { const result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE id = ${id} AND user_did = ${userDid} `; return result.count > 0; } export async function markAllNotificationsRead( userDid: string, opts: { spaceSlug?: string; category?: string } = {}, ): Promise { let result; if (opts.spaceSlug && opts.category) { result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE user_did = ${userDid} AND NOT read AND space_slug = ${opts.spaceSlug} AND category = ${opts.category} `; } else if (opts.spaceSlug) { result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE user_did = ${userDid} AND NOT read AND space_slug = ${opts.spaceSlug} `; } else if (opts.category) { result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE user_did = ${userDid} AND NOT read AND category = ${opts.category} `; } else { result = await sql` UPDATE notifications SET read = TRUE, read_at = NOW() WHERE user_did = ${userDid} AND NOT read `; } return result.count; } export async function dismissNotification(id: string, userDid: string): Promise { const result = await sql` UPDATE notifications SET dismissed = TRUE WHERE id = ${id} AND user_did = ${userDid} `; return result.count > 0; } export async function markNotificationDelivered(id: string, channel: 'ws' | 'email' | 'push'): Promise { const col = channel === 'ws' ? 'delivered_ws' : channel === 'email' ? 'delivered_email' : 'delivered_push'; await sql.unsafe(`UPDATE notifications SET ${col} = TRUE WHERE id = $1`, [id]); } export async function cleanExpiredNotifications(): Promise { const result = await sql`DELETE FROM notifications WHERE expires_at IS NOT NULL AND expires_at < NOW()`; return result.count; } // ── Notification Preferences ── export interface StoredNotificationPreferences { userDid: string; emailEnabled: boolean; pushEnabled: boolean; quietHoursStart: string | null; quietHoursEnd: string | null; mutedSpaces: string[]; mutedCategories: string[]; digestFrequency: 'none' | 'daily' | 'weekly'; updatedAt: string; } function rowToPreferences(row: any): StoredNotificationPreferences { return { userDid: row.user_did, emailEnabled: row.email_enabled, pushEnabled: row.push_enabled, quietHoursStart: row.quiet_hours_start || null, quietHoursEnd: row.quiet_hours_end || null, mutedSpaces: row.muted_spaces || [], mutedCategories: row.muted_categories || [], digestFrequency: row.digest_frequency || 'none', updatedAt: row.updated_at?.toISOString?.() || new Date(row.updated_at).toISOString(), }; } export async function getNotificationPreferences(userDid: string): Promise { const [row] = await sql`SELECT * FROM notification_preferences WHERE user_did = ${userDid}`; if (!row) return null; return rowToPreferences(row); } export async function upsertNotificationPreferences( userDid: string, prefs: Partial>, ): Promise { const rows = await sql` INSERT INTO notification_preferences (user_did, email_enabled, push_enabled, quiet_hours_start, quiet_hours_end, muted_spaces, muted_categories, digest_frequency) VALUES ( ${userDid}, ${prefs.emailEnabled ?? true}, ${prefs.pushEnabled ?? true}, ${prefs.quietHoursStart || null}, ${prefs.quietHoursEnd || null}, ${prefs.mutedSpaces || []}, ${prefs.mutedCategories || []}, ${prefs.digestFrequency || 'none'} ) ON CONFLICT (user_did) DO UPDATE SET email_enabled = COALESCE(${prefs.emailEnabled ?? null}::boolean, notification_preferences.email_enabled), push_enabled = COALESCE(${prefs.pushEnabled ?? null}::boolean, notification_preferences.push_enabled), quiet_hours_start = COALESCE(${prefs.quietHoursStart ?? null}, notification_preferences.quiet_hours_start), quiet_hours_end = COALESCE(${prefs.quietHoursEnd ?? null}, notification_preferences.quiet_hours_end), muted_spaces = COALESCE(${prefs.mutedSpaces ?? null}, notification_preferences.muted_spaces), muted_categories = COALESCE(${prefs.mutedCategories ?? null}, notification_preferences.muted_categories), digest_frequency = COALESCE(${prefs.digestFrequency ?? null}, notification_preferences.digest_frequency), updated_at = NOW() RETURNING * `; return rowToPreferences(rows[0]); } // ============================================================================ // FUND CLAIM OPERATIONS // ============================================================================ export interface StoredFundClaim { id: string; token: string; emailHash: string; email: string | null; walletAddress: string; openfortPlayerId: string | null; fiatAmount: string | null; fiatCurrency: string; sessionId: string | null; provider: string | null; status: 'pending' | 'claimed' | 'expired' | 'resent'; claimedByUserId: string | null; createdAt: number; expiresAt: number; claimedAt: number | null; } function rowToFundClaim(row: any): StoredFundClaim { return { id: row.id, token: row.token, emailHash: row.email_hash, email: row.email || null, walletAddress: row.wallet_address, openfortPlayerId: row.openfort_player_id || null, fiatAmount: row.fiat_amount || null, fiatCurrency: row.fiat_currency || 'USD', sessionId: row.session_id || null, provider: row.provider || null, status: row.status, claimedByUserId: row.claimed_by_user_id || null, createdAt: new Date(row.created_at).getTime(), expiresAt: new Date(row.expires_at).getTime(), claimedAt: row.claimed_at ? new Date(row.claimed_at).getTime() : null, }; } export async function createFundClaim(claim: { id: string; token: string; emailHash: string; email: string; walletAddress: string; openfortPlayerId?: string; fiatAmount?: string; fiatCurrency?: string; sessionId?: string; provider?: string; expiresAt: number; }): Promise { const rows = await sql` INSERT INTO fund_claims (id, token, email_hash, email, wallet_address, openfort_player_id, fiat_amount, fiat_currency, session_id, provider, expires_at) VALUES ( ${claim.id}, ${claim.token}, ${claim.emailHash}, ${claim.email}, ${claim.walletAddress}, ${claim.openfortPlayerId || null}, ${claim.fiatAmount || null}, ${claim.fiatCurrency || 'USD'}, ${claim.sessionId || null}, ${claim.provider || null}, ${new Date(claim.expiresAt)} ) RETURNING * `; return rowToFundClaim(rows[0]); } export async function getFundClaimByToken(token: string): Promise { const rows = await sql`SELECT * FROM fund_claims WHERE token = ${token}`; if (rows.length === 0) return null; return rowToFundClaim(rows[0]); } export async function getFundClaimsByEmailHash(emailHash: string): Promise { const rows = await sql` SELECT * FROM fund_claims WHERE email_hash = ${emailHash} AND status IN ('pending', 'resent') AND expires_at > NOW() ORDER BY created_at DESC `; return rows.map(rowToFundClaim); } export async function acceptFundClaim(token: string, userId: string): Promise { const rows = await sql` UPDATE fund_claims SET status = 'claimed', claimed_by_user_id = ${userId}, claimed_at = NOW(), email = NULL WHERE token = ${token} AND status IN ('pending', 'resent') AND expires_at > NOW() RETURNING * `; if (rows.length === 0) return null; return rowToFundClaim(rows[0]); } export async function accumulateFundClaim(claimId: string, additionalAmount: string, expiresAt: number): Promise { const rows = await sql` UPDATE fund_claims SET fiat_amount = (COALESCE(fiat_amount::numeric, 0) + ${additionalAmount}::numeric)::text, expires_at = ${new Date(expiresAt)} WHERE id = ${claimId} AND status IN ('pending', 'resent') RETURNING * `; if (rows.length === 0) return null; return rowToFundClaim(rows[0]); } export async function expireFundClaim(claimId: string): Promise { await sql`UPDATE fund_claims SET status = 'expired', email = NULL WHERE id = ${claimId} AND status IN ('pending', 'resent')`; } export async function cleanExpiredFundClaims(): Promise { // Null out email on expired claims, then mark them expired await sql`UPDATE fund_claims SET email = NULL, status = 'expired' WHERE status IN ('pending', 'resent') AND expires_at < NOW()`; const result = await sql`DELETE FROM fund_claims WHERE status = 'expired' AND expires_at < NOW() - INTERVAL '30 days'`; return result.count; } export { sql };