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

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)) }
});
}