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