if (process.env.NODE_ENV !== "test") require("server-only"); import getPool from "@/lib/server/db"; import { apiError } from "@/lib/server/errors"; import { getGroupRole, isAdminRole, requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access"; import { recordGroupAudit } from "@/lib/server/group-audit"; import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; export type GroupRole = "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER"; export type GroupMember = { userId: number; email: string; displayName: string | null; role: GroupRole; joinedAt: string; lastLoginAt: string | null; lastActiveAt: string | null; }; export type JoinRequest = { id: number; groupId: number; userId: number; email: string; displayName: string | null; status: "PENDING" | "APPROVED" | "DENIED" | "CANCELED"; createdAt: string; }; type GroupMemberRow = { user_id: number; role: GroupRole; created_at: string; last_active_at: string | null; email: string; display_name: string | null; last_login_at: string | null; }; type JoinRequestRow = { id: number; group_id: number; user_id: number; status: "PENDING" | "APPROVED" | "DENIED" | "CANCELED"; created_at: string; email: string; display_name: string | null; }; export async function listGroupMembers(groupId: number): Promise { const pool = getPool(); const { rows } = await pool.query( `select gm.user_id, gm.role, gm.created_at, gm.last_active_at, u.email, u.display_name, u.last_login_at from group_members gm join users u on u.id = gm.user_id where gm.group_id=$1 order by gm.created_at asc`, [groupId] ); return rows.map((row: GroupMemberRow) => ({ userId: Number(row.user_id), email: row.email, displayName: row.display_name, role: row.role as GroupRole, joinedAt: row.created_at, lastLoginAt: row.last_login_at, lastActiveAt: row.last_active_at })); } export async function listJoinRequests(input: { userId: number; groupId: number }) { await requireGroupAdmin(input.userId, input.groupId); const pool = getPool(); const { rows } = await pool.query( `select r.id, r.group_id, r.user_id, r.status, r.created_at, u.email, u.display_name from group_join_requests r join users u on u.id = r.user_id where r.group_id=$1 and r.status='PENDING' order by r.created_at asc`, [input.groupId] ); return rows.map((row: JoinRequestRow) => ({ id: Number(row.id), groupId: Number(row.group_id), userId: Number(row.user_id), email: row.email, displayName: row.display_name, status: row.status, createdAt: row.created_at })) as JoinRequest[]; } export async function createJoinRequest(input: { userId: number; groupId: number }) { const pool = getPool(); const role = await getGroupRole(input.userId, input.groupId); if (role) return; await pool.query( `insert into group_join_requests(group_id, user_id, status) values($1,$2,'PENDING') on conflict (group_id, user_id) where status='PENDING' do update set updated_at=now()`, [input.groupId, input.userId] ); } export async function approveJoinRequest(input: { actorUserId: number; groupId: number; userId: number; requestId: string; ip?: string | null; userAgent?: string | null; requestRowId?: number }) { await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:approve" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const client = await pool.connect(); let approvedUserId = input.userId; try { await client.query("begin"); const { rows } = await client.query( input.requestRowId ? `select id, status, user_id from group_join_requests where group_id=$1 and id=$2 for update` : `select id, status, user_id from group_join_requests where group_id=$1 and user_id=$2 for update`, input.requestRowId ? [input.groupId, input.requestRowId] : [input.groupId, input.userId] ); const request = rows[0]; if (!request || request.status !== "PENDING") apiError("JOIN_REQUEST_NOT_FOUND", { groupId: input.groupId, userId: input.userId }); approvedUserId = Number(request.user_id || input.userId); await client.query( `update group_join_requests set status='APPROVED', decided_by=$3, decided_at=now(), updated_at=now() where group_id=$1 and user_id=$2`, [input.groupId, approvedUserId, input.actorUserId] ); await client.query( `insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER') on conflict (group_id, user_id) do nothing`, [input.groupId, approvedUserId] ); await client.query("commit"); } catch (e) { await client.query("rollback"); throw e; } finally { client.release(); } await recordGroupAudit({ groupId: input.groupId, actorUserId: input.actorUserId, actorRole: role, eventType: "GROUP_JOIN_APPROVED", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent, metadata: { targetUserId: approvedUserId } }); } export async function denyJoinRequest(input: { actorUserId: number; groupId: number; userId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:deny" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const { rows } = await pool.query( `update group_join_requests set status='DENIED', decided_by=$3, decided_at=now(), updated_at=now() where group_id=$1 and user_id=$2 and status='PENDING'`, [input.groupId, input.userId, input.actorUserId] ); if (!rows.length) apiError("JOIN_REQUEST_NOT_FOUND", { groupId: input.groupId, userId: input.userId }); await recordGroupAudit({ groupId: input.groupId, actorUserId: input.actorUserId, actorRole: role, eventType: "GROUP_JOIN_DENIED", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent, metadata: { targetUserId: input.userId } }); } export async function kickMember(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:kick" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const client = await pool.connect(); try { await client.query("begin"); const { rows } = await client.query( "select role from group_members where group_id=$1 and user_id=$2 for update", [input.groupId, input.targetUserId] ); const targetRole = rows[0]?.role as GroupRole | undefined; if (!targetRole) apiError("NOT_MEMBER", { groupId: input.groupId, userId: input.targetUserId }); if (targetRole === "GROUP_OWNER") apiError("FORBIDDEN", { groupId: input.groupId, userId: input.targetUserId }); const countRes = await client.query( "select count(*)::int as count from group_members where group_id=$1", [input.groupId] ); if (Number(countRes.rows[0]?.count || 0) <= 1) apiError("FORBIDDEN", { groupId: input.groupId, userId: input.targetUserId }); await client.query( "delete from group_members where group_id=$1 and user_id=$2", [input.groupId, input.targetUserId] ); await client.query("commit"); } catch (e) { await client.query("rollback"); throw e; } finally { client.release(); } await recordGroupAudit({ groupId: input.groupId, actorUserId: input.actorUserId, actorRole: role, eventType: "GROUP_MEMBER_KICKED", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent, metadata: { targetUserId: input.targetUserId } }); } export async function leaveGroup(input: { userId: number; groupId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:members:leave" }); const pool = getPool(); const client = await pool.connect(); let role: GroupRole | null = null; try { await client.query("begin"); const { rows } = await client.query( "select role from group_members where group_id=$1 and user_id=$2 for update", [input.groupId, input.userId] ); role = (rows[0]?.role as GroupRole | undefined) ?? null; if (!role) apiError("NOT_MEMBER", { groupId: input.groupId, userId: input.userId }); if (role === "GROUP_OWNER") apiError("OWNER_MUST_TRANSFER", { groupId: input.groupId, userId: input.userId }); const countRes = await client.query( "select count(*)::int as count from group_members where group_id=$1", [input.groupId] ); if (Number(countRes.rows[0]?.count || 0) <= 1) apiError("CANNOT_LEAVE_LAST_MEMBER", { groupId: input.groupId, userId: input.userId }); await client.query( "delete from group_members where group_id=$1 and user_id=$2", [input.groupId, input.userId] ); await client.query("commit"); } catch (e) { await client.query("rollback"); throw e; } finally { client.release(); } await recordGroupAudit({ groupId: input.groupId, actorUserId: input.userId, actorRole: role, eventType: "GROUP_MEMBER_LEFT", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent }); } export async function promoteToAdmin(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:promote" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const { rows } = await pool.query( "select role from group_members where group_id=$1 and user_id=$2", [input.groupId, input.targetUserId] ); const targetRole = rows[0]?.role as GroupRole | undefined; if (!targetRole) apiError("NOT_MEMBER", { groupId: input.groupId, userId: input.targetUserId }); if (targetRole === "GROUP_OWNER") apiError("FORBIDDEN", { groupId: input.groupId, userId: input.targetUserId }); await pool.query( "update group_members set role='GROUP_ADMIN' where group_id=$1 and user_id=$2", [input.groupId, input.targetUserId] ); await recordGroupAudit({ groupId: input.groupId, actorUserId: input.actorUserId, actorRole: role, eventType: "GROUP_MEMBER_PROMOTED", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent, metadata: { targetUserId: input.targetUserId } }); } export async function demoteAdmin(input: { actorUserId: number; groupId: number; targetUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:demote" }); const role = await requireGroupAdmin(input.actorUserId, input.groupId); const pool = getPool(); const { rows } = await pool.query( "select role from group_members where group_id=$1 and user_id=$2", [input.groupId, input.targetUserId] ); const targetRole = rows[0]?.role as GroupRole | undefined; if (!targetRole) apiError("NOT_MEMBER", { groupId: input.groupId, userId: input.targetUserId }); if (targetRole === "GROUP_OWNER") apiError("FORBIDDEN", { groupId: input.groupId, userId: input.targetUserId }); if (targetRole !== "GROUP_ADMIN") return; await pool.query( "update group_members set role='MEMBER' where group_id=$1 and user_id=$2", [input.groupId, input.targetUserId] ); await recordGroupAudit({ groupId: input.groupId, actorUserId: input.actorUserId, actorRole: role, eventType: "GROUP_MEMBER_DEMOTED", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent, metadata: { targetUserId: input.targetUserId } }); } export async function transferOwnership(input: { actorUserId: number; groupId: number; newOwnerUserId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { await enforceUserWriteRateLimit({ userId: input.actorUserId, scope: "groups:members:transfer-owner" }); await requireGroupOwner(input.actorUserId, input.groupId); const pool = getPool(); const client = await pool.connect(); try { await client.query("begin"); const { rows: targetRows } = await client.query( "select role from group_members where group_id=$1 and user_id=$2 for update", [input.groupId, input.newOwnerUserId] ); const targetRole = targetRows[0]?.role as GroupRole | undefined; if (!targetRole) apiError("NOT_MEMBER", { groupId: input.groupId, userId: input.newOwnerUserId }); await client.query( "update group_members set role='GROUP_ADMIN' where group_id=$1 and user_id=$2", [input.groupId, input.actorUserId] ); await client.query( "update group_members set role='GROUP_OWNER' where group_id=$1 and user_id=$2", [input.groupId, input.newOwnerUserId] ); await client.query("commit"); } catch (e) { await client.query("rollback"); throw e; } finally { client.release(); } await recordGroupAudit({ groupId: input.groupId, actorUserId: input.actorUserId, actorRole: "GROUP_OWNER", eventType: "GROUP_OWNERSHIP_TRANSFERRED", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent, metadata: { newOwnerUserId: input.newOwnerUserId } }); }