187 lines
6.8 KiB
TypeScript
187 lines
6.8 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";
|
|
import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit";
|
|
|
|
function createInviteCode() {
|
|
return crypto.randomBytes(4).toString("hex").toUpperCase();
|
|
}
|
|
|
|
function last4(value: string) {
|
|
return value.slice(-4);
|
|
}
|
|
|
|
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) {
|
|
await enforceUserWriteRateLimit({ userId, scope: "groups:active:set" });
|
|
const pool = getPool();
|
|
const { rows } = await pool.query(
|
|
"select 1 from group_members where group_id=$1 and user_id=$2",
|
|
[groupId, userId]
|
|
);
|
|
if (!rows.length) apiError("FORBIDDEN", { userId, groupId });
|
|
await setActiveGroupId(userId, groupId);
|
|
}
|
|
|
|
export async function createGroup(userId: number, name: string) {
|
|
await enforceUserWriteRateLimit({ userId, scope: "groups:create" });
|
|
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 }) {
|
|
await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:rename" });
|
|
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 enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:delete" });
|
|
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) {
|
|
await enforceUserWriteRateLimit({ userId, scope: "groups:join" });
|
|
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", { inviteCodeLast4: last4(inviteCode) });
|
|
|
|
const existing = await pool.query(
|
|
"select 1 from group_members where group_id=$1 and user_id=$2",
|
|
[group.id, userId]
|
|
);
|
|
if (existing.rows.length)
|
|
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;
|
|
}
|