costco-grocery-list/backend/models/group-invites.model.js

393 lines
8.8 KiB
JavaScript

const pool = require("../db/pool");
function getExecutor(client) {
return client || pool;
}
async function withTransaction(handler) {
const client = await pool.connect();
try {
await client.query("BEGIN");
const result = await handler(client);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
async function getManageableGroupsForUser(userId, client) {
const result = await getExecutor(client).query(
`SELECT household_id AS group_id
FROM household_members
WHERE user_id = $1
AND role IN ('owner', 'admin')`,
[userId]
);
return result.rows;
}
async function getUserGroupRole(groupId, userId, client) {
const result = await getExecutor(client).query(
`SELECT role
FROM household_members
WHERE household_id = $1
AND user_id = $2`,
[groupId, userId]
);
return result.rows[0]?.role || null;
}
async function getGroupById(groupId, client) {
const result = await getExecutor(client).query(
`SELECT id, name
FROM households
WHERE id = $1`,
[groupId]
);
return result.rows[0] || null;
}
async function listInviteLinks(groupId, client) {
const result = await getExecutor(client).query(
`SELECT
id,
group_id,
created_by,
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`,
[groupId]
);
return result.rows;
}
async function createInviteLink(
{ groupId, createdBy, token, policy, singleUse, expiresAt },
client
) {
const result = await getExecutor(client).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,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at`,
[groupId, createdBy, token, policy, singleUse, expiresAt]
);
return result.rows[0];
}
async function getInviteLinkById(groupId, linkId, client) {
const result = await getExecutor(client).query(
`SELECT
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at
FROM group_invite_links
WHERE group_id = $1
AND id = $2`,
[groupId, linkId]
);
return result.rows[0] || null;
}
async function revokeInviteLink(groupId, linkId, client) {
const result = await getExecutor(client).query(
`UPDATE group_invite_links
SET revoked_at = NOW()
WHERE group_id = $1
AND id = $2
RETURNING
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at`,
[groupId, linkId]
);
return result.rows[0] || null;
}
async function reviveInviteLink(groupId, linkId, expiresAt, client) {
const result = await getExecutor(client).query(
`UPDATE group_invite_links
SET used_at = NULL,
revoked_at = NULL,
expires_at = $3
WHERE group_id = $1
AND id = $2
RETURNING
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at`,
[groupId, linkId, expiresAt]
);
return result.rows[0] || null;
}
async function deleteInviteLink(groupId, linkId, client) {
const result = await getExecutor(client).query(
`DELETE FROM group_invite_links
WHERE group_id = $1
AND id = $2
RETURNING
id,
group_id,
created_by,
token,
policy,
single_use,
expires_at,
used_at,
revoked_at,
created_at`,
[groupId, linkId]
);
return result.rows[0] || null;
}
async function getInviteLinkSummaryByToken(token, client, forUpdate = false) {
const result = await getExecutor(client).query(
`SELECT
gil.id,
gil.group_id,
gil.created_by,
gil.token,
gil.policy,
gil.single_use,
gil.expires_at,
gil.used_at,
gil.revoked_at,
gil.created_at,
h.name AS group_name,
gs.join_policy AS current_join_policy
FROM group_invite_links gil
JOIN households h ON h.id = gil.group_id
LEFT JOIN group_settings gs ON gs.group_id = gil.group_id
WHERE gil.token = $1
${forUpdate ? "FOR UPDATE OF gil" : ""}`,
[token]
);
return result.rows[0] || null;
}
async function isGroupMember(groupId, userId, client) {
const result = await getExecutor(client).query(
`SELECT 1
FROM household_members
WHERE household_id = $1
AND user_id = $2`,
[groupId, userId]
);
return result.rows.length > 0;
}
async function getPendingJoinRequest(groupId, userId, client) {
const result = await getExecutor(client).query(
`SELECT id, group_id, user_id, status, created_at, updated_at
FROM group_join_requests
WHERE group_id = $1
AND user_id = $2
AND status = 'PENDING'`,
[groupId, userId]
);
return result.rows[0] || null;
}
async function createOrTouchPendingJoinRequest(groupId, userId, client) {
const executor = getExecutor(client);
const existing = await executor.query(
`UPDATE group_join_requests
SET updated_at = NOW()
WHERE group_id = $1
AND user_id = $2
AND status = 'PENDING'
RETURNING id, group_id, user_id, status, created_at, updated_at`,
[groupId, userId]
);
if (existing.rows[0]) {
return existing.rows[0];
}
try {
const inserted = await executor.query(
`INSERT INTO group_join_requests (group_id, user_id, status)
VALUES ($1, $2, 'PENDING')
RETURNING id, group_id, user_id, status, created_at, updated_at`,
[groupId, userId]
);
return inserted.rows[0];
} catch (error) {
if (error.code !== "23505") {
throw error;
}
const fallback = await executor.query(
`SELECT id, group_id, user_id, status, created_at, updated_at
FROM group_join_requests
WHERE group_id = $1
AND user_id = $2
AND status = 'PENDING'
LIMIT 1`,
[groupId, userId]
);
return fallback.rows[0] || null;
}
}
async function addGroupMember(groupId, userId, role = "member", client) {
const result = await getExecutor(client).query(
`INSERT INTO household_members (household_id, user_id, role)
VALUES ($1, $2, $3)
ON CONFLICT (household_id, user_id) DO NOTHING
RETURNING id`,
[groupId, userId, role]
);
return result.rows.length > 0;
}
async function consumeSingleUseInvite(linkId, client) {
const result = await getExecutor(client).query(
`UPDATE group_invite_links
SET used_at = NOW(),
revoked_at = NOW()
WHERE id = $1
RETURNING id`,
[linkId]
);
return result.rows.length > 0;
}
async function getGroupSettings(groupId, client) {
const result = await getExecutor(client).query(
`SELECT group_id, join_policy
FROM group_settings
WHERE group_id = $1`,
[groupId]
);
return result.rows[0] || null;
}
async function upsertGroupSettings(groupId, joinPolicy, client) {
const result = await getExecutor(client).query(
`INSERT INTO group_settings (group_id, join_policy)
VALUES ($1, $2)
ON CONFLICT (group_id)
DO UPDATE SET
join_policy = EXCLUDED.join_policy,
updated_at = NOW()
RETURNING group_id, join_policy`,
[groupId, joinPolicy]
);
return result.rows[0];
}
async function createGroupAuditLog(
{
groupId,
actorUserId,
actorRole,
eventType,
requestId,
ip,
userAgent,
success = true,
errorCode = null,
metadata = {},
},
client
) {
const result = await getExecutor(client).query(
`INSERT INTO group_audit_log (
group_id,
actor_user_id,
actor_role,
event_type,
request_id,
ip,
user_agent,
success,
error_code,
metadata
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING id`,
[
groupId,
actorUserId,
actorRole,
eventType,
requestId,
ip,
userAgent,
success,
errorCode,
JSON.stringify(metadata || {}),
]
);
return result.rows[0];
}
module.exports = {
addGroupMember,
createGroupAuditLog,
createInviteLink,
createOrTouchPendingJoinRequest,
consumeSingleUseInvite,
deleteInviteLink,
getGroupById,
getGroupSettings,
getInviteLinkById,
getInviteLinkSummaryByToken,
getManageableGroupsForUser,
getPendingJoinRequest,
getUserGroupRole,
isGroupMember,
listInviteLinks,
revokeInviteLink,
reviveInviteLink,
upsertGroupSettings,
withTransaction,
};