rspace-online/src/encryptid/db.ts

1313 lines
42 KiB
TypeScript

/**
* 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<void> {
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<void> {
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<void> {
// 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<StoredCredential | null> {
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<void> {
await sql`
UPDATE credentials
SET counter = ${newCounter}, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
}
export async function getUserCredentials(userId: string): Promise<StoredCredential[]> {
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<void> {
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<StoredChallenge | null> {
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<void> {
await sql`DELETE FROM challenges WHERE challenge = ${challenge}`;
}
export async function cleanExpiredChallenges(): Promise<number> {
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<void> {
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<void> {
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<StoredRecoveryToken | null> {
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<void> {
await sql`UPDATE recovery_tokens SET used = TRUE WHERE token = ${token}`;
}
export async function cleanExpiredRecoveryTokens(): Promise<number> {
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<StoredSpaceMember[]> {
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<StoredSpaceMember | null> {
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<StoredSpaceMember[]> {
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<StoredSpaceMember> {
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<boolean> {
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<StoredGuardian> {
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<StoredGuardian[]> {
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<StoredGuardian | null> {
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<void> {
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<boolean> {
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<StoredGuardian | null> {
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<StoredGuardian[]> {
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<StoredRecoveryRequest> {
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<StoredRecoveryRequest | null> {
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<StoredRecoveryRequest | null> {
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<void> {
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<void> {
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<Array<{ guardianId: string; approvedAt: number | null }>> {
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<void> {
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<void> {
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<StoredUserProfile | null> {
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<StoredUserProfile | null> {
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<StoredEncryptedAddress[]> {
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<StoredEncryptedAddress | null> {
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<StoredEncryptedAddress> {
// 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<boolean> {
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<void> {
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<AdminUserInfo[]> {
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<boolean> {
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<number> {
const result = await sql`DELETE FROM space_members WHERE space_slug = ${spaceSlug}`;
return result.count;
}
// ============================================================================
// HEALTH CHECK
// ============================================================================
export async function checkDatabaseHealth(): Promise<boolean> {
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<StoredSpaceInvite> {
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<StoredSpaceInvite | null> {
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<StoredSpaceInvite[]> {
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<StoredSpaceInvite | null> {
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<boolean> {
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<string, any>;
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<string, any>;
expiresAt?: Date;
}): Promise<StoredNotification> {
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<StoredNotification[]> {
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<number> {
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<boolean> {
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<number> {
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<boolean> {
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<void> {
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<number> {
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<StoredNotificationPreferences | null> {
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<Omit<StoredNotificationPreferences, 'userDid' | 'updatedAt'>>,
): Promise<StoredNotificationPreferences> {
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<StoredFundClaim> {
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<StoredFundClaim | null> {
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<StoredFundClaim[]> {
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<StoredFundClaim | null> {
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<StoredFundClaim | null> {
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<void> {
await sql`UPDATE fund_claims SET status = 'expired', email = NULL WHERE id = ${claimId} AND status IN ('pending', 'resent')`;
}
export async function cleanExpiredFundClaims(): Promise<number> {
// 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 };