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

372 lines
14 KiB
TypeScript

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";
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<GroupMember[]> {
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 }) {
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 }) {
const role = await requireGroupAdmin(input.actorUserId, input.groupId);
const pool = getPool();
const { rowCount } = 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 (!rowCount) 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 }) {
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 }) {
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 }) {
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 }) {
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 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 }
});
}