From beb9cdcec7cb486efb58c9cfa06d7d1fb4afc809 Mon Sep 17 00:00:00 2001 From: Nico Date: Sat, 21 Feb 2026 00:07:11 -0800 Subject: [PATCH] fix(invites): lock invite row without outer join update error --- backend/models/group-invites.model.js | 392 ++++++++++++++++++++++++++ 1 file changed, 392 insertions(+) create mode 100644 backend/models/group-invites.model.js diff --git a/backend/models/group-invites.model.js b/backend/models/group-invites.model.js new file mode 100644 index 0000000..058def0 --- /dev/null +++ b/backend/models/group-invites.model.js @@ -0,0 +1,392 @@ +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, +};