354 lines
12 KiB
TypeScript
354 lines
12 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 { requireGroupAdmin } from "@/lib/server/group-access";
|
|
import { recordGroupAudit } from "@/lib/server/group-audit";
|
|
|
|
export type JoinPolicy = "NOT_ACCEPTING" | "AUTO_ACCEPT" | "APPROVAL_REQUIRED";
|
|
|
|
export type InviteLink = {
|
|
id: number;
|
|
groupId: number;
|
|
token: string;
|
|
policy: JoinPolicy;
|
|
singleUse: boolean;
|
|
groupJoinPolicy: JoinPolicy;
|
|
expiresAt: string;
|
|
usedAt: string | null;
|
|
revokedAt: string | null;
|
|
createdAt: string;
|
|
};
|
|
|
|
export type InviteLinkSummary = {
|
|
id: number;
|
|
groupId: number;
|
|
groupName: string;
|
|
policy: JoinPolicy;
|
|
groupJoinPolicy: JoinPolicy;
|
|
singleUse: boolean;
|
|
expiresAt: string;
|
|
usedAt: string | null;
|
|
revokedAt: string | null;
|
|
createdAt: string;
|
|
viewerStatus?: "ALREADY_MEMBER" | "PENDING";
|
|
};
|
|
|
|
export async function getInviteViewerStatus(input: { userId: number; groupId: number }) {
|
|
const pool = getPool();
|
|
const existing = await pool.query(
|
|
"select 1 from group_members where group_id=$1 and user_id=$2",
|
|
[input.groupId, input.userId]
|
|
);
|
|
if (existing.rowCount) return "ALREADY_MEMBER" as const;
|
|
const pending = await pool.query(
|
|
"select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'",
|
|
[input.groupId, input.userId]
|
|
);
|
|
if (pending.rowCount) return "PENDING" as const;
|
|
return null;
|
|
}
|
|
|
|
type InviteLinkRow = {
|
|
id: number;
|
|
group_id: number;
|
|
token: string;
|
|
policy: JoinPolicy;
|
|
single_use: boolean;
|
|
expires_at: string;
|
|
used_at: string | null;
|
|
revoked_at: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
function createToken() {
|
|
return crypto.randomBytes(16).toString("hex");
|
|
}
|
|
|
|
function last4(value: string) {
|
|
return value.slice(-4);
|
|
}
|
|
|
|
export async function createInviteLink(input: {
|
|
userId: number;
|
|
groupId: number;
|
|
policy: JoinPolicy;
|
|
singleUse: boolean;
|
|
expiresAt: Date;
|
|
requestId: string;
|
|
ip?: string | null;
|
|
userAgent?: string | null;
|
|
}) {
|
|
const role = await requireGroupAdmin(input.userId, input.groupId);
|
|
const pool = getPool();
|
|
const token = createToken();
|
|
const { rows } = await pool.query(
|
|
`insert into group_invite_links(group_id, created_by, token, policy, single_use, expires_at)
|
|
values($1,$2,$3,$4,$5,$6)
|
|
returning id, group_id, token, policy, single_use, expires_at, used_at, revoked_at, created_at`,
|
|
[input.groupId, input.userId, token, input.policy, input.singleUse, input.expiresAt]
|
|
);
|
|
const row = rows[0];
|
|
await recordGroupAudit({
|
|
groupId: input.groupId,
|
|
actorUserId: input.userId,
|
|
actorRole: role,
|
|
eventType: "GROUP_INVITE_CREATED",
|
|
requestId: input.requestId,
|
|
ip: input.ip,
|
|
userAgent: input.userAgent,
|
|
metadata: {
|
|
inviteCodeLast4: last4(token),
|
|
policy: input.policy,
|
|
singleUse: input.singleUse,
|
|
expiresAt: input.expiresAt.toISOString()
|
|
}
|
|
});
|
|
return {
|
|
id: Number(row.id),
|
|
groupId: Number(row.group_id),
|
|
token: String(row.token),
|
|
policy: row.policy as JoinPolicy,
|
|
singleUse: Boolean(row.single_use),
|
|
expiresAt: row.expires_at,
|
|
usedAt: row.used_at,
|
|
revokedAt: row.revoked_at,
|
|
createdAt: row.created_at
|
|
} satisfies InviteLink;
|
|
}
|
|
|
|
export async function listInviteLinks(input: { userId: number; groupId: number }) {
|
|
await requireGroupAdmin(input.userId, input.groupId);
|
|
const pool = getPool();
|
|
const { rows } = await pool.query(
|
|
`select id, group_id, token, policy, single_use, expires_at, used_at, revoked_at, created_at
|
|
from group_invite_links
|
|
where group_id=$1
|
|
order by created_at desc`,
|
|
[input.groupId]
|
|
);
|
|
return rows.map((row: InviteLinkRow) => ({
|
|
id: Number(row.id),
|
|
groupId: Number(row.group_id),
|
|
token: String(row.token),
|
|
policy: row.policy as JoinPolicy,
|
|
singleUse: Boolean(row.single_use),
|
|
expiresAt: row.expires_at,
|
|
usedAt: row.used_at,
|
|
revokedAt: row.revoked_at,
|
|
createdAt: row.created_at
|
|
})) as InviteLink[];
|
|
}
|
|
|
|
export async function revokeInviteLink(input: { userId: number; groupId: number; linkId: number; 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 group_invite_links
|
|
set revoked_at=now()
|
|
where id=$1 and group_id=$2
|
|
returning token`,
|
|
[input.linkId, input.groupId]
|
|
);
|
|
if (!rows[0]) apiError("INVITE_NOT_FOUND", { groupId: input.groupId, linkId: input.linkId });
|
|
await recordGroupAudit({
|
|
groupId: input.groupId,
|
|
actorUserId: input.userId,
|
|
actorRole: role,
|
|
eventType: "GROUP_INVITE_REVOKED",
|
|
requestId: input.requestId,
|
|
ip: input.ip,
|
|
userAgent: input.userAgent,
|
|
metadata: { inviteCodeLast4: last4(String(rows[0].token)) }
|
|
});
|
|
}
|
|
|
|
export async function reviveInviteLink(input: { userId: number; groupId: number; linkId: number; expiresAt: Date; 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 group_invite_links
|
|
set used_at=null,
|
|
revoked_at=null,
|
|
expires_at=$3
|
|
where id=$1 and group_id=$2
|
|
returning token`,
|
|
[input.linkId, input.groupId, input.expiresAt]
|
|
);
|
|
if (!rows[0]) apiError("INVITE_NOT_FOUND", { groupId: input.groupId, linkId: input.linkId });
|
|
await recordGroupAudit({
|
|
groupId: input.groupId,
|
|
actorUserId: input.userId,
|
|
actorRole: role,
|
|
eventType: "GROUP_INVITE_REVIVED",
|
|
requestId: input.requestId,
|
|
ip: input.ip,
|
|
userAgent: input.userAgent,
|
|
metadata: {
|
|
inviteCodeLast4: last4(String(rows[0].token)),
|
|
expiresAt: input.expiresAt.toISOString()
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function getInviteLinkByToken(token: string) {
|
|
const pool = getPool();
|
|
const { rows } = await pool.query(
|
|
`select id, group_id, token, policy, single_use, expires_at, used_at, revoked_at, created_at
|
|
from group_invite_links
|
|
where token=$1`,
|
|
[token]
|
|
);
|
|
const row = rows[0];
|
|
if (!row) return null;
|
|
return {
|
|
id: Number(row.id),
|
|
groupId: Number(row.group_id),
|
|
token: String(row.token),
|
|
policy: row.policy as JoinPolicy,
|
|
singleUse: Boolean(row.single_use),
|
|
expiresAt: row.expires_at,
|
|
usedAt: row.used_at,
|
|
revokedAt: row.revoked_at,
|
|
createdAt: row.created_at
|
|
} satisfies InviteLink;
|
|
}
|
|
|
|
export async function getInviteLinkSummaryByToken(token: string) {
|
|
const pool = getPool();
|
|
const { rows } = await pool.query(
|
|
`select l.id, l.group_id, g.name as group_name, l.policy, l.single_use, l.expires_at, l.used_at, l.revoked_at, l.created_at,
|
|
gs.join_policy as group_join_policy
|
|
from group_invite_links l
|
|
join groups g on g.id = l.group_id
|
|
left join group_settings gs on gs.group_id = l.group_id
|
|
where l.token=$1`,
|
|
[token]
|
|
);
|
|
const row = rows[0];
|
|
if (!row) return null;
|
|
return {
|
|
id: Number(row.id),
|
|
groupId: Number(row.group_id),
|
|
groupName: String(row.group_name),
|
|
policy: row.policy as JoinPolicy,
|
|
groupJoinPolicy: row.group_join_policy as JoinPolicy,
|
|
singleUse: Boolean(row.single_use),
|
|
expiresAt: row.expires_at,
|
|
usedAt: row.used_at,
|
|
revokedAt: row.revoked_at,
|
|
createdAt: row.created_at
|
|
} satisfies InviteLinkSummary;
|
|
}
|
|
|
|
export async function acceptInviteLink(input: { userId: number; token: string; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
|
const summary = await getInviteLinkSummaryByToken(input.token);
|
|
if (!summary) apiError("INVITE_NOT_FOUND", { tokenLast4: last4(input.token) });
|
|
|
|
const pool = getPool();
|
|
const existing = await pool.query(
|
|
"select 1 from group_members where group_id=$1 and user_id=$2",
|
|
[summary.groupId, input.userId]
|
|
);
|
|
if (existing.rowCount) {
|
|
return { status: "ALREADY_MEMBER" as const, group: { id: summary.groupId, name: summary.groupName } };
|
|
}
|
|
|
|
const pending = await pool.query(
|
|
"select 1 from group_join_requests where group_id=$1 and user_id=$2 and status='PENDING'",
|
|
[summary.groupId, input.userId]
|
|
);
|
|
if (pending.rowCount) {
|
|
return { status: "PENDING" as const, group: { id: summary.groupId, name: summary.groupName } };
|
|
}
|
|
|
|
if (summary.revokedAt) apiError("INVITE_REVOKED", { groupId: summary.groupId, linkId: summary.id });
|
|
const expiresAtMs = new Date(summary.expiresAt).getTime();
|
|
if (Number.isNaN(expiresAtMs) || Date.now() > expiresAtMs)
|
|
apiError("INVITE_EXPIRED", { groupId: summary.groupId, linkId: summary.id });
|
|
if (summary.singleUse && summary.usedAt)
|
|
apiError("INVITE_USED", { groupId: summary.groupId, linkId: summary.id });
|
|
|
|
const activePolicy = summary.groupJoinPolicy || summary.policy;
|
|
if (activePolicy === "NOT_ACCEPTING")
|
|
apiError("JOIN_NOT_ACCEPTING", { groupId: summary.groupId, userId: input.userId });
|
|
|
|
if (activePolicy === "AUTO_ACCEPT") {
|
|
await pool.query(
|
|
"insert into group_members(group_id, user_id, role) values($1,$2,'MEMBER') on conflict do nothing",
|
|
[summary.groupId, input.userId]
|
|
);
|
|
if (summary.singleUse) {
|
|
await pool.query(
|
|
"update group_invite_links set used_at=now(), revoked_at=now() where id=$1",
|
|
[summary.id]
|
|
);
|
|
}
|
|
await recordGroupAudit({
|
|
groupId: summary.groupId,
|
|
actorUserId: input.userId,
|
|
actorRole: "MEMBER",
|
|
eventType: "GROUP_INVITE_USED",
|
|
requestId: input.requestId,
|
|
ip: input.ip,
|
|
userAgent: input.userAgent,
|
|
metadata: { inviteCodeLast4: last4(input.token) }
|
|
});
|
|
return { status: "JOINED" as const, group: { id: summary.groupId, name: summary.groupName } };
|
|
}
|
|
|
|
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()`,
|
|
[summary.groupId, input.userId]
|
|
);
|
|
if (summary.singleUse) {
|
|
await pool.query(
|
|
"update group_invite_links set used_at=now(), revoked_at=now() where id=$1",
|
|
[summary.id]
|
|
);
|
|
}
|
|
await recordGroupAudit({
|
|
groupId: summary.groupId,
|
|
actorUserId: input.userId,
|
|
actorRole: "MEMBER",
|
|
eventType: "GROUP_INVITE_REQUESTED",
|
|
requestId: input.requestId,
|
|
ip: input.ip,
|
|
userAgent: input.userAgent,
|
|
metadata: { inviteCodeLast4: last4(input.token) }
|
|
});
|
|
return { status: "PENDING" as const, group: { id: summary.groupId, name: summary.groupName } };
|
|
}
|
|
|
|
export async function markInviteLinkUsed(input: { linkId: number }) {
|
|
const pool = getPool();
|
|
await pool.query(
|
|
"update group_invite_links set used_at=now() where id=$1",
|
|
[input.linkId]
|
|
);
|
|
}
|
|
|
|
export async function deleteInviteLink(input: { userId: number; groupId: number; linkId: number; requestId: string; ip?: string | null; userAgent?: string | null }) {
|
|
const role = await requireGroupAdmin(input.userId, input.groupId);
|
|
const pool = getPool();
|
|
const { rows } = await pool.query(
|
|
"delete from group_invite_links where id=$1 and group_id=$2 returning token",
|
|
[input.linkId, input.groupId]
|
|
);
|
|
if (!rows[0]) apiError("INVITE_NOT_FOUND", { groupId: input.groupId, linkId: input.linkId });
|
|
await recordGroupAudit({
|
|
groupId: input.groupId,
|
|
actorUserId: input.userId,
|
|
actorRole: role,
|
|
eventType: "GROUP_INVITE_DELETED",
|
|
requestId: input.requestId,
|
|
ip: input.ip,
|
|
userAgent: input.userAgent,
|
|
metadata: { inviteCodeLast4: last4(String(rows[0].token)) }
|
|
});
|
|
}
|