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
558 lines
14 KiB
JavaScript
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,
|
|
};
|