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"; import { enforceUserWriteRateLimit } from "@/lib/server/rate-limit"; 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.rows.length) 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.rows.length) 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; }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:create" }); 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), groupJoinPolicy: row.policy as JoinPolicy, 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), groupJoinPolicy: row.policy as JoinPolicy, 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 }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:revoke" }); 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 }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:revive" }); 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), groupJoinPolicy: row.policy as JoinPolicy, 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 }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:accept" }); 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.rows.length) { 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.rows.length) { 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 }) { await enforceUserWriteRateLimit({ userId: input.userId, scope: "groups:invites:delete" }); 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)) } }); }