if (process.env.NODE_ENV !== "test") require("server-only"); import crypto from "node:crypto"; import getPool from "@/lib/server/db"; import { apiError } from "@/lib/server/errors"; import type { Group } from "@/lib/shared/types"; import { requireGroupAdmin, requireGroupOwner } from "@/lib/server/group-access"; import { recordGroupAudit } from "@/lib/server/group-audit"; function createInviteCode() { return crypto.randomBytes(4).toString("hex").toUpperCase(); } type GroupRow = { id: number; name: string; role: "MEMBER" | "GROUP_ADMIN" | "GROUP_OWNER"; }; export async function listGroups(userId: number): Promise { const pool = getPool(); const rows = (await pool.query( `select g.id, g.name, gm.role from group_members gm join groups g on g.id = gm.group_id where gm.user_id = $1 order by g.created_at desc`, [userId] )).rows; return rows.map((row: GroupRow) => ({ id: Number(row.id), name: row.name, role: row.role })); } export async function getActiveGroupId(userId: number): Promise { const pool = getPool(); const { rows } = await pool.query( "select data->>'activeGroupId' as active_group_id from user_settings where user_id=$1", [userId] ); const value = rows[0]?.active_group_id; if (!value) return null; const id = Number(value); return Number.isFinite(id) ? id : null; } export async function setActiveGroupId(userId: number, groupId: number) { const pool = getPool(); await pool.query( `insert into user_settings(user_id, data) values($1, jsonb_build_object('activeGroupId', $2::int)) on conflict (user_id) do update set data = user_settings.data || jsonb_build_object('activeGroupId', $2::int), updated_at = now()`, [userId, groupId] ); } export async function setActiveGroupForUser(userId: number, groupId: number) { const pool = getPool(); const { rowCount } = await pool.query( "select 1 from group_members where group_id=$1 and user_id=$2", [groupId, userId] ); if (!rowCount) apiError("FORBIDDEN", { userId, groupId }); await setActiveGroupId(userId, groupId); } export async function createGroup(userId: number, name: string) { const pool = getPool(); const inviteCode = createInviteCode(); const { rows } = await pool.query( "insert into groups(name, invite_code, created_by) values($1,$2,$3) returning id, name, invite_code", [name, inviteCode, userId] ); const group = rows[0]; await pool.query( "insert into group_members(group_id, user_id, role) values($1,$2,'GROUP_OWNER')", [group.id, userId] ); await pool.query( "insert into group_settings(group_id, allow_member_tag_manage, join_policy) values($1,false,'NOT_ACCEPTING') on conflict (group_id) do nothing", [group.id] ); return { id: group.id, name: group.name, inviteCode: group.invite_code } as { id: number; name: string; inviteCode: string }; } export async function renameGroup(input: { userId: number; groupId: number; name: string; requestId: string; ip?: string | null; userAgent?: string | null }) { const role = await requireGroupAdmin(input.userId, input.groupId); const pool = getPool(); const { rows } = await pool.query( "update groups set name=$2 where id=$1 returning name", [input.groupId, input.name] ); if (!rows[0]) apiError("NOT_FOUND", { groupId: input.groupId }); await recordGroupAudit({ groupId: input.groupId, actorUserId: input.userId, actorRole: role, eventType: "GROUP_RENAMED", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent, metadata: { name: input.name } }); } export async function deleteGroup(input: { userId: number; groupId: number; requestId: string; ip?: string | null; userAgent?: string | null }) { await requireGroupOwner(input.userId, input.groupId); const pool = getPool(); await pool.query( "delete from groups where id=$1", [input.groupId] ); await recordGroupAudit({ groupId: input.groupId, actorUserId: input.userId, actorRole: "GROUP_OWNER", eventType: "GROUP_DELETED", requestId: input.requestId, ip: input.ip, userAgent: input.userAgent }); } export async function joinGroup(userId: number, inviteCode: string) { const pool = getPool(); const { rows } = await pool.query( "select id, name from groups where invite_code=$1", [inviteCode] ); const group = rows[0]; if (!group) apiError("INVALID_INVITE", { inviteCode }); const existing = await pool.query( "select 1 from group_members where group_id=$1 and user_id=$2", [group.id, userId] ); if (existing.rowCount) return { id: group.id, name: group.name } as { id: number; name: string }; const settings = await pool.query( "select join_policy from group_settings where group_id=$1", [group.id] ); const joinPolicy = String(settings.rows[0]?.join_policy || "NOT_ACCEPTING") as "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED"; if (joinPolicy === "NOT_ACCEPTING") apiError("JOIN_NOT_ACCEPTING", { groupId: group.id, userId }); if (joinPolicy === "AUTO_ACCEPT") { await pool.query( "insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER')", [group.id, userId] ); return { id: group.id, name: group.name } as { id: number; name: string }; } 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()`, [group.id, userId] ); apiError("JOIN_PENDING", { groupId: group.id, userId }); } export async function requireActiveGroup(userId: number): Promise { const activeGroupId = await getActiveGroupId(userId); if (!activeGroupId) apiError("NO_ACTIVE_GROUP", { userId }); const groups = await listGroups(userId); const isMember = groups.some(group => Number(group.id) === activeGroupId); if (!isMember) apiError("FORBIDDEN", { userId, groupId: activeGroupId }); return activeGroupId; }