fix(invites): lock invite row without outer join update error
This commit is contained in:
parent
9fa48e6eb3
commit
beb9cdcec7
392
backend/models/group-invites.model.js
Normal file
392
backend/models/group-invites.model.js
Normal file
@ -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,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user