380 lines
14 KiB
TypeScript
380 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";
|
|
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<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 }) {
|
|
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 }
|
|
});
|
|
}
|