const crypto = require("crypto"); const net = require("net"); const invitesModel = require("../models/group-invites.model"); const { inviteCodeLast4 } = require("../utils/redaction"); const JOIN_POLICIES = Object.freeze({ NOT_ACCEPTING: "NOT_ACCEPTING", AUTO_ACCEPT: "AUTO_ACCEPT", APPROVAL_REQUIRED: "APPROVAL_REQUIRED", }); const JOIN_RESULTS = Object.freeze({ JOINED: "JOINED", PENDING: "PENDING", ALREADY_MEMBER: "ALREADY_MEMBER", }); class InviteServiceError extends Error { constructor(code, message, statusCode = 400) { super(message); this.name = "InviteServiceError"; this.code = code; this.statusCode = statusCode; } } function normalizeIp(ip) { if (!ip || typeof ip !== "string") return null; const trimmed = ip.trim(); if (!trimmed) return null; return net.isIP(trimmed) ? trimmed : null; } function ensureJoinPolicy(policy) { if (Object.values(JOIN_POLICIES).includes(policy)) { return policy; } throw new InviteServiceError( "INVALID_JOIN_POLICY", "Invalid join policy", 400 ); } function ensurePositiveInteger(value, fieldName) { const parsed = Number.parseInt(value, 10); if (!Number.isInteger(parsed) || parsed <= 0) { throw new InviteServiceError("INVALID_INPUT", `${fieldName} is required`, 400); } return parsed; } function ensureDate(value, fieldName) { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { throw new InviteServiceError("INVALID_INPUT", `${fieldName} is invalid`, 400); } return date; } async function ensureGroupAndManagerRole(userId, groupId, client) { const group = await invitesModel.getGroupById(groupId, client); if (!group) { throw new InviteServiceError("GROUP_NOT_FOUND", "Group not found", 404); } const actorRole = await invitesModel.getUserGroupRole(groupId, userId, client); if (!["owner", "admin"].includes(actorRole)) { throw new InviteServiceError( "FORBIDDEN", "Admin or owner role required", 403 ); } return { actorRole, group }; } async function resolveManagedGroupId(userId, requestedGroupId) { if (requestedGroupId !== undefined && requestedGroupId !== null) { return ensurePositiveInteger(requestedGroupId, "groupId"); } const manageableGroups = await invitesModel.getManageableGroupsForUser(userId); if (manageableGroups.length === 0) { throw new InviteServiceError( "FORBIDDEN", "Admin or owner role required", 403 ); } if (manageableGroups.length > 1) { throw new InviteServiceError( "GROUP_ID_REQUIRED", "Group ID is required when you manage multiple groups", 400 ); } return manageableGroups[0].group_id; } async function createInviteLink( userId, groupId, policy, singleUse, expiresAt, requestId, ip, userAgent ) { const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); const resolvedPolicy = ensureJoinPolicy(policy); const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt"); return invitesModel.withTransaction(async (client) => { const { actorRole } = await ensureGroupAndManagerRole( userId, resolvedGroupId, client ); let link = null; for (let attempt = 0; attempt < 5; attempt += 1) { const token = crypto.randomBytes(16).toString("hex"); try { link = await invitesModel.createInviteLink( { groupId: resolvedGroupId, createdBy: userId, token, policy: resolvedPolicy, singleUse: Boolean(singleUse), expiresAt: resolvedExpiresAt, }, client ); break; } catch (error) { if (error.code !== "23505") { throw error; } } } if (!link) { throw new InviteServiceError( "INVITE_CREATE_FAILED", "Unable to create invite link", 500 ); } await invitesModel.createGroupAuditLog( { groupId: resolvedGroupId, actorUserId: userId, actorRole, eventType: "GROUP_INVITE_CREATED", requestId, ip: normalizeIp(ip), userAgent: userAgent || null, metadata: { inviteCodeLast4: inviteCodeLast4(link.token), }, }, client ); return link; }); } async function listInviteLinks(userId, groupId) { const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); await ensureGroupAndManagerRole(userId, resolvedGroupId); return invitesModel.listInviteLinks(resolvedGroupId); } async function revokeInviteLink( userId, groupId, linkId, requestId, ip, userAgent ) { const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); const resolvedLinkId = ensurePositiveInteger(linkId, "linkId"); return invitesModel.withTransaction(async (client) => { const { actorRole } = await ensureGroupAndManagerRole( userId, resolvedGroupId, client ); const link = await invitesModel.revokeInviteLink( resolvedGroupId, resolvedLinkId, client ); if (!link) { throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); } await invitesModel.createGroupAuditLog( { groupId: resolvedGroupId, actorUserId: userId, actorRole, eventType: "GROUP_INVITE_REVOKED", requestId, ip: normalizeIp(ip), userAgent: userAgent || null, metadata: { inviteCodeLast4: inviteCodeLast4(link.token), }, }, client ); }); } async function reviveInviteLink( userId, groupId, linkId, expiresAt, requestId, ip, userAgent ) { const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); const resolvedLinkId = ensurePositiveInteger(linkId, "linkId"); const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt"); return invitesModel.withTransaction(async (client) => { const { actorRole } = await ensureGroupAndManagerRole( userId, resolvedGroupId, client ); const link = await invitesModel.reviveInviteLink( resolvedGroupId, resolvedLinkId, resolvedExpiresAt, client ); if (!link) { throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); } await invitesModel.createGroupAuditLog( { groupId: resolvedGroupId, actorUserId: userId, actorRole, eventType: "GROUP_INVITE_REVIVED", requestId, ip: normalizeIp(ip), userAgent: userAgent || null, metadata: { inviteCodeLast4: inviteCodeLast4(link.token), }, }, client ); }); } async function deleteInviteLink( userId, groupId, linkId, requestId, ip, userAgent ) { const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); const resolvedLinkId = ensurePositiveInteger(linkId, "linkId"); return invitesModel.withTransaction(async (client) => { const { actorRole } = await ensureGroupAndManagerRole( userId, resolvedGroupId, client ); const link = await invitesModel.deleteInviteLink( resolvedGroupId, resolvedLinkId, client ); if (!link) { throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); } await invitesModel.createGroupAuditLog( { groupId: resolvedGroupId, actorUserId: userId, actorRole, eventType: "GROUP_INVITE_DELETED", requestId, ip: normalizeIp(ip), userAgent: userAgent || null, metadata: { inviteCodeLast4: inviteCodeLast4(link.token), }, }, client ); }); } function getInviteStatus(link) { const now = Date.now(); if (link.single_use && link.used_at) return "USED"; if (link.revoked_at) return "REVOKED"; if (new Date(link.expires_at).getTime() <= now) return "EXPIRED"; return "ACTIVE"; } async function getInviteLinkSummaryByToken(token, userId = null) { if (!token || typeof token !== "string") { throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400); } const summary = await invitesModel.getInviteLinkSummaryByToken(token.trim()); if (!summary) { throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); } let viewerStatus = null; if (userId) { const isMember = await invitesModel.isGroupMember(summary.group_id, userId); if (isMember) { viewerStatus = JOIN_RESULTS.ALREADY_MEMBER; } else { const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId); if (pending) { viewerStatus = JOIN_RESULTS.PENDING; } } } const activePolicy = summary.current_join_policy || summary.policy; return { id: summary.id, group_id: summary.group_id, group_name: summary.group_name, token: summary.token, policy: summary.policy, current_join_policy: summary.current_join_policy || null, active_policy: activePolicy, single_use: summary.single_use, expires_at: summary.expires_at, used_at: summary.used_at, revoked_at: summary.revoked_at, created_at: summary.created_at, status: getInviteStatus(summary), ...(viewerStatus ? { viewerStatus } : {}), }; } async function acceptInviteLink(userId, token, requestId, ip, userAgent) { if (!userId) { throw new InviteServiceError("UNAUTHORIZED", "Authentication required", 401); } if (!token || typeof token !== "string") { throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400); } return invitesModel.withTransaction(async (client) => { const summary = await invitesModel.getInviteLinkSummaryByToken( token.trim(), client, true ); if (!summary) { throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404); } const group = { id: summary.group_id, name: summary.group_name, }; const memberExists = await invitesModel.isGroupMember(summary.group_id, userId, client); if (memberExists) { return { status: JOIN_RESULTS.ALREADY_MEMBER, group }; } const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId, client); if (pending) { return { status: JOIN_RESULTS.PENDING, group }; } const now = Date.now(); if (summary.revoked_at) { throw new InviteServiceError( "INVITE_REVOKED", "This invite link has been revoked", 410 ); } if (new Date(summary.expires_at).getTime() <= now) { throw new InviteServiceError( "INVITE_EXPIRED", "This invite link has expired", 410 ); } if (summary.single_use && summary.used_at) { throw new InviteServiceError( "INVITE_USED", "This invite link has already been used", 410 ); } const activePolicy = summary.current_join_policy || summary.policy || JOIN_POLICIES.NOT_ACCEPTING; if (activePolicy === JOIN_POLICIES.NOT_ACCEPTING) { throw new InviteServiceError( "JOIN_NOT_ACCEPTING", "This group is not accepting new members", 403 ); } const actorRole = (await invitesModel.getUserGroupRole(summary.group_id, userId, client)) || "guest"; if (activePolicy === JOIN_POLICIES.AUTO_ACCEPT) { const inserted = await invitesModel.addGroupMember( summary.group_id, userId, "member", client ); if (!inserted) { return { status: JOIN_RESULTS.ALREADY_MEMBER, group }; } if (summary.single_use) { await invitesModel.consumeSingleUseInvite(summary.id, client); } await invitesModel.createGroupAuditLog( { groupId: summary.group_id, actorUserId: userId, actorRole, eventType: "GROUP_INVITE_USED", requestId, ip: normalizeIp(ip), userAgent: userAgent || null, metadata: { inviteCodeLast4: inviteCodeLast4(summary.token), }, }, client ); return { status: JOIN_RESULTS.JOINED, group }; } if (activePolicy === JOIN_POLICIES.APPROVAL_REQUIRED) { await invitesModel.createOrTouchPendingJoinRequest(summary.group_id, userId, client); if (summary.single_use) { await invitesModel.consumeSingleUseInvite(summary.id, client); } await invitesModel.createGroupAuditLog( { groupId: summary.group_id, actorUserId: userId, actorRole, eventType: "GROUP_INVITE_REQUESTED", requestId, ip: normalizeIp(ip), userAgent: userAgent || null, metadata: { inviteCodeLast4: inviteCodeLast4(summary.token), }, }, client ); return { status: JOIN_RESULTS.PENDING, group }; } throw new InviteServiceError("INVALID_JOIN_POLICY", "Invalid join policy", 400); }); } async function getGroupJoinPolicy(userId, groupId) { const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); await ensureGroupAndManagerRole(userId, resolvedGroupId); const settings = await invitesModel.getGroupSettings(resolvedGroupId); return settings?.join_policy || JOIN_POLICIES.NOT_ACCEPTING; } async function setGroupJoinPolicy( userId, groupId, joinPolicy, requestId, ip, userAgent ) { const resolvedGroupId = ensurePositiveInteger(groupId, "groupId"); const resolvedJoinPolicy = ensureJoinPolicy(joinPolicy); return invitesModel.withTransaction(async (client) => { const { actorRole } = await ensureGroupAndManagerRole( userId, resolvedGroupId, client ); await invitesModel.upsertGroupSettings(resolvedGroupId, resolvedJoinPolicy, client); await invitesModel.createGroupAuditLog( { groupId: resolvedGroupId, actorUserId: userId, actorRole, eventType: "GROUP_JOIN_POLICY_UPDATED", requestId, ip: normalizeIp(ip), userAgent: userAgent || null, metadata: { joinPolicy: resolvedJoinPolicy, }, }, client ); }); } module.exports = { InviteServiceError, JOIN_POLICIES, JOIN_RESULTS, acceptInviteLink, createInviteLink, deleteInviteLink, getGroupJoinPolicy, getInviteLinkSummaryByToken, listInviteLinks, resolveManagedGroupId, revokeInviteLink, reviveInviteLink, setGroupJoinPolicy, };