costco-grocery-list/backend/services/group-invites.service.js
Nico 77ae5be445
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m10s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 3s
Build & Deploy Costco Grocery List / deploy (push) Successful in 11s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s
refactor
2026-02-22 01:27:03 -08:00

558 lines
14 KiB
JavaScript

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