fiddy/apps/web/lib/server/groups.ts
2026-02-11 23:45:15 -08:00

177 lines
6.3 KiB
TypeScript

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<Group[]> {
const pool = getPool();
const rows = (await pool.query<GroupRow>(
`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<number | null> {
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<number> {
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;
}