// Admin-only helpers. Every call is RLS-gated server-side via the // `current_user_is_admin()` helper + admin-specific policies. Non-admins // trying to call these still get clean PostgREST 403/empty-result responses. import type { AppSupabaseClient } from '../supabase/client.js'; // --- Admin settings ------------------------------------------------------- export interface AdminSetting { key: string; value: unknown; updatedAt: string; } export async function listAdminSettings(client: AppSupabaseClient): Promise { const { data, error } = await client .from('admin_settings') .select('key, value, updated_at') .order('key', { ascending: true }); if (error) throw error; return (data ?? []).map((r) => ({ key: r.key, value: r.value, updatedAt: r.updated_at })); } export async function updateAdminSetting( client: AppSupabaseClient, key: string, value: unknown, ): Promise { const { error } = await client .from('admin_settings') .upsert( { key, value: value as never, updated_at: new Date().toISOString(), } as never, { onConflict: 'key' }, ); if (error) throw error; } // --- Invite codes --------------------------------------------------------- export interface InviteRecord { code: string; createdBy: string | null; usesLimit: number | null; usesCount: number; expiresAt: string | null; disabled: boolean; createdAt: string; } function mapInvite(row: { code: string; created_by: string | null; uses_limit: number | null; uses_count: number; expires_at: string | null; disabled: boolean; created_at: string; }): InviteRecord { return { code: row.code, createdBy: row.created_by, usesLimit: row.uses_limit, usesCount: row.uses_count, expiresAt: row.expires_at, disabled: row.disabled, createdAt: row.created_at, }; } export async function listInvites(client: AppSupabaseClient): Promise { const { data, error } = await client .from('invites') .select('code, created_by, uses_limit, uses_count, expires_at, disabled, created_at') .order('created_at', { ascending: false }); if (error) throw error; return (data ?? []).map(mapInvite); } export interface CreateInviteParams { code?: string; // omit to auto-generate usesLimit?: number | null; expiresAt?: string | null; // ISO } const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; function randomCode(length = 10): string { const buf = typeof crypto !== 'undefined' && 'getRandomValues' in crypto ? crypto.getRandomValues(new Uint8Array(length)) : null; let out = ''; for (let i = 0; i < length; i++) { const v = buf ? buf[i]! : Math.floor(Math.random() * 256); const ch = INVITE_ALPHABET[v % INVITE_ALPHABET.length]; if (ch !== undefined) out += ch; } return out; } export async function createInvite( client: AppSupabaseClient, params: CreateInviteParams = {}, ): Promise { const { data: session, error: aErr } = await client.auth.getUser(); if (aErr) throw aErr; if (!session.user) throw new Error('not authenticated'); const code = params.code?.trim() || randomCode(10); const { data, error } = await client .from('invites') .insert({ code, created_by: session.user.id, uses_limit: params.usesLimit ?? null, expires_at: params.expiresAt ?? null, disabled: false, } as never) .select('code, created_by, uses_limit, uses_count, expires_at, disabled, created_at') .single(); if (error) throw error; return mapInvite(data as never); } export async function setInviteDisabled( client: AppSupabaseClient, code: string, disabled: boolean, ): Promise { const { error } = await client .from('invites') .update({ disabled } as never) .eq('code', code); if (error) throw error; } export async function deleteInvite(client: AppSupabaseClient, code: string): Promise { const { error } = await client.from('invites').delete().eq('code', code); if (error) throw error; } // --- User admin ops ------------------------------------------------------- export interface AdminProfileRow { userId: string; username: string; displayName: string; isAdmin: boolean; banned: boolean; blockedFromInviting: boolean; createdAt: string; } export async function listAllProfiles(client: AppSupabaseClient): Promise { const { data, error } = await client .from('profiles') .select('user_id, username, display_name, is_admin, banned, blocked_from_inviting, created_at') .order('created_at', { ascending: false }); if (error) throw error; return (data ?? []).map((r) => ({ userId: r.user_id, username: r.username, displayName: r.display_name, isAdmin: r.is_admin, banned: r.banned, blockedFromInviting: r.blocked_from_inviting, createdAt: r.created_at, })); } export type AdminProfileFlag = 'is_admin' | 'banned' | 'blocked_from_inviting'; export async function setUserFlag( client: AppSupabaseClient, userId: string, flag: AdminProfileFlag, value: boolean, ): Promise { const patch: Record = {}; patch[flag] = value; const { error } = await client .from('profiles') .update(patch as never) .eq('user_id', userId); if (error) throw error; } // --------------------------------------------------------------------------- // Conversations (admin view — reads all regardless of membership) // --------------------------------------------------------------------------- export interface AdminConversationRow { id: string; type: 'dm' | 'group'; name: string | null; memberCount: number; lastMessageAt: string | null; createdAt: string; } export async function listAllConversations( client: AppSupabaseClient, ): Promise { const { data, error } = await client .from('conversations') .select('id, type, name, created_at') .order('created_at', { ascending: false }); if (error) throw error; const conversations = data ?? []; if (conversations.length === 0) return []; const ids = conversations.map((c) => c.id); const { data: memberRows, error: mErr } = await client .from('conversation_members') .select('conversation_id') .in('conversation_id', ids); if (mErr) throw mErr; const counts = new Map(); for (const row of memberRows ?? []) { counts.set(row.conversation_id, (counts.get(row.conversation_id) ?? 0) + 1); } // Approximate "last message at" by peeking at latest message.created_at per // conversation. Cheap since conversation count stays small (<20 users, // (); for (const row of lastMsg ?? []) { if (!lastAt.has(row.conversation_id)) { lastAt.set(row.conversation_id, row.created_at); } } return conversations.map((c) => ({ id: c.id, type: c.type as 'dm' | 'group', name: c.name, memberCount: counts.get(c.id) ?? 0, lastMessageAt: lastAt.get(c.id) ?? null, createdAt: c.created_at, })); } // --------------------------------------------------------------------------- // Audit log // --------------------------------------------------------------------------- export interface AdminAuditEntry { id: string; actorId: string | null; action: string; targetType: string | null; targetId: string | null; metadata: Record; createdAt: string; } export async function listAuditLog( client: AppSupabaseClient, limit = 100, ): Promise { const { data, error } = await (client as unknown as { from: (t: string) => { select: (cols: string) => { order: ( col: string, opts: { ascending: boolean }, ) => { limit: (n: number) => Promise<{ data: | { id: string; actor_id: string | null; action: string; target_type: string | null; target_id: string | null; metadata: Record; created_at: string; }[] | null; error: Error | null; }>; }; }; }; }) .from('admin_audit_log') .select('id, actor_id, action, target_type, target_id, metadata, created_at') .order('created_at', { ascending: false }) .limit(limit); if (error) throw error; return (data ?? []).map((r) => ({ id: r.id, actorId: r.actor_id, action: r.action, targetType: r.target_type, targetId: r.target_id, metadata: r.metadata ?? {}, createdAt: r.created_at, })); } export async function logAdminAction( client: AppSupabaseClient, action: string, target?: { type?: string; id?: string }, metadata?: Record, ): Promise { const { error } = await (client as unknown as { rpc: ( name: string, args: Record, ) => Promise<{ error: Error | null }>; }).rpc('admin_log_action', { p_action: action, p_target_type: target?.type ?? null, p_target_id: target?.id ?? null, p_metadata: metadata ?? {}, }); if (error) throw error; }