feat: add invite link approval management
This commit is contained in:
parent
4aff7e78f2
commit
9bdf2247f4
@ -79,6 +79,20 @@ exports.createInviteLink = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.listPendingJoinRequests = async (req, res) => {
|
||||
try {
|
||||
const requestedGroupId = parseRequestedGroupId(req);
|
||||
const groupId = await invitesService.resolveManagedGroupId(
|
||||
req.user.id,
|
||||
requestedGroupId
|
||||
);
|
||||
const requests = await invitesService.listPendingJoinRequests(req.user.id, groupId);
|
||||
res.json({ requests });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.listPendingJoinRequests");
|
||||
}
|
||||
};
|
||||
|
||||
exports.revokeInviteLink = async (req, res) => {
|
||||
try {
|
||||
const requestedGroupId = parseRequestedGroupId(req);
|
||||
@ -180,6 +194,28 @@ exports.setJoinPolicy = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
exports.decideJoinRequest = async (req, res) => {
|
||||
try {
|
||||
const requestedGroupId = parseRequestedGroupId(req);
|
||||
const groupId = await invitesService.resolveManagedGroupId(
|
||||
req.user.id,
|
||||
requestedGroupId
|
||||
);
|
||||
const decision = await invitesService.decideJoinRequest(
|
||||
req.user.id,
|
||||
groupId,
|
||||
req.body?.requestId,
|
||||
req.body?.decision,
|
||||
req.request_id,
|
||||
getClientIp(req),
|
||||
req.headers["user-agent"] || null
|
||||
);
|
||||
res.json({ request: decision });
|
||||
} catch (error) {
|
||||
return mapServiceError(req, res, error, "groupInvites.decideJoinRequest");
|
||||
}
|
||||
};
|
||||
|
||||
exports.getInviteLinkSummary = async (req, res) => {
|
||||
const token = req.params.token;
|
||||
const inviteLast4 = inviteCodeLast4(token);
|
||||
|
||||
@ -237,6 +237,53 @@ async function getPendingJoinRequest(groupId, userId, client) {
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function listPendingJoinRequests(groupId, client) {
|
||||
const result = await getExecutor(client).query(
|
||||
`SELECT
|
||||
gjr.id,
|
||||
gjr.group_id,
|
||||
gjr.user_id,
|
||||
gjr.status,
|
||||
gjr.created_at,
|
||||
gjr.updated_at,
|
||||
u.username,
|
||||
u.name,
|
||||
u.display_name
|
||||
FROM group_join_requests gjr
|
||||
JOIN users u ON u.id = gjr.user_id
|
||||
WHERE gjr.group_id = $1
|
||||
AND gjr.status = 'PENDING'
|
||||
ORDER BY gjr.created_at ASC`,
|
||||
[groupId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async function getPendingJoinRequestById(groupId, requestId, client, forUpdate = false) {
|
||||
const result = await getExecutor(client).query(
|
||||
`SELECT
|
||||
gjr.id,
|
||||
gjr.group_id,
|
||||
gjr.user_id,
|
||||
gjr.status,
|
||||
gjr.decided_by,
|
||||
gjr.decided_at,
|
||||
gjr.created_at,
|
||||
gjr.updated_at,
|
||||
u.username,
|
||||
u.name,
|
||||
u.display_name
|
||||
FROM group_join_requests gjr
|
||||
JOIN users u ON u.id = gjr.user_id
|
||||
WHERE gjr.group_id = $1
|
||||
AND gjr.id = $2
|
||||
AND gjr.status = 'PENDING'
|
||||
${forUpdate ? "FOR UPDATE OF gjr" : ""}`,
|
||||
[groupId, requestId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function createOrTouchPendingJoinRequest(groupId, userId, client) {
|
||||
const executor = getExecutor(client);
|
||||
const existing = await executor.query(
|
||||
@ -277,6 +324,22 @@ async function createOrTouchPendingJoinRequest(groupId, userId, client) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateJoinRequestDecision(groupId, requestId, status, decidedBy, client) {
|
||||
const result = await getExecutor(client).query(
|
||||
`UPDATE group_join_requests
|
||||
SET status = $3,
|
||||
decided_by = $4,
|
||||
decided_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE group_id = $1
|
||||
AND id = $2
|
||||
AND status = 'PENDING'
|
||||
RETURNING id, group_id, user_id, status, decided_by, decided_at, created_at, updated_at`,
|
||||
[groupId, requestId, status, decidedBy]
|
||||
);
|
||||
return result.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)
|
||||
@ -381,12 +444,15 @@ module.exports = {
|
||||
getInviteLinkById,
|
||||
getInviteLinkSummaryByToken,
|
||||
getManageableGroupsForUser,
|
||||
getPendingJoinRequestById,
|
||||
getPendingJoinRequest,
|
||||
getUserGroupRole,
|
||||
isGroupMember,
|
||||
listPendingJoinRequests,
|
||||
listInviteLinks,
|
||||
revokeInviteLink,
|
||||
reviveInviteLink,
|
||||
updateJoinRequestDecision,
|
||||
upsertGroupSettings,
|
||||
withTransaction,
|
||||
};
|
||||
|
||||
@ -28,6 +28,13 @@ const inviteWriteUserRateLimit = createRateLimit({
|
||||
|
||||
router.get("/groups/invites", auth, controller.listInviteLinks);
|
||||
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink);
|
||||
router.get("/groups/join-requests", auth, controller.listPendingJoinRequests);
|
||||
router.post(
|
||||
"/groups/join-requests/decision",
|
||||
auth,
|
||||
inviteWriteUserRateLimit,
|
||||
controller.decideJoinRequest
|
||||
);
|
||||
router.post(
|
||||
"/groups/invites/revoke",
|
||||
auth,
|
||||
|
||||
@ -179,6 +179,12 @@ async function listInviteLinks(userId, groupId) {
|
||||
return invitesModel.listInviteLinks(resolvedGroupId);
|
||||
}
|
||||
|
||||
async function listPendingJoinRequests(userId, groupId) {
|
||||
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||
await ensureGroupAndManagerRole(userId, resolvedGroupId);
|
||||
return invitesModel.listPendingJoinRequests(resolvedGroupId);
|
||||
}
|
||||
|
||||
async function revokeInviteLink(
|
||||
userId,
|
||||
groupId,
|
||||
@ -314,6 +320,116 @@ async function deleteInviteLink(
|
||||
});
|
||||
}
|
||||
|
||||
async function decideJoinRequest(
|
||||
userId,
|
||||
groupId,
|
||||
requestId,
|
||||
decision,
|
||||
requestIdForAudit,
|
||||
ip,
|
||||
userAgent
|
||||
) {
|
||||
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
|
||||
const resolvedRequestId = ensurePositiveInteger(requestId, "requestId");
|
||||
const normalizedDecision = typeof decision === "string" ? decision.trim().toUpperCase() : "";
|
||||
|
||||
if (!["APPROVE", "DENY"].includes(normalizedDecision)) {
|
||||
throw new InviteServiceError("INVALID_INPUT", "Decision is required", 400);
|
||||
}
|
||||
|
||||
return invitesModel.withTransaction(async (client) => {
|
||||
const { actorRole } = await ensureGroupAndManagerRole(
|
||||
userId,
|
||||
resolvedGroupId,
|
||||
client
|
||||
);
|
||||
const pendingRequest = await invitesModel.getPendingJoinRequestById(
|
||||
resolvedGroupId,
|
||||
resolvedRequestId,
|
||||
client,
|
||||
true
|
||||
);
|
||||
|
||||
if (!pendingRequest) {
|
||||
throw new InviteServiceError(
|
||||
"JOIN_REQUEST_NOT_FOUND",
|
||||
"Pending join request not found",
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
if (normalizedDecision === "APPROVE") {
|
||||
const isExistingMember = await invitesModel.isGroupMember(
|
||||
resolvedGroupId,
|
||||
pendingRequest.user_id,
|
||||
client
|
||||
);
|
||||
if (!isExistingMember) {
|
||||
await invitesModel.addGroupMember(
|
||||
resolvedGroupId,
|
||||
pendingRequest.user_id,
|
||||
"member",
|
||||
client
|
||||
);
|
||||
}
|
||||
|
||||
const approvedRequest = await invitesModel.updateJoinRequestDecision(
|
||||
resolvedGroupId,
|
||||
resolvedRequestId,
|
||||
"APPROVED",
|
||||
userId,
|
||||
client
|
||||
);
|
||||
|
||||
await invitesModel.createGroupAuditLog(
|
||||
{
|
||||
groupId: resolvedGroupId,
|
||||
actorUserId: userId,
|
||||
actorRole,
|
||||
eventType: "GROUP_JOIN_REQUEST_APPROVED",
|
||||
requestId: requestIdForAudit,
|
||||
ip: normalizeIp(ip),
|
||||
userAgent: userAgent || null,
|
||||
metadata: {
|
||||
joinRequestId: approvedRequest.id,
|
||||
targetUserId: approvedRequest.user_id,
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
|
||||
return approvedRequest;
|
||||
}
|
||||
|
||||
const deniedRequest = await invitesModel.updateJoinRequestDecision(
|
||||
resolvedGroupId,
|
||||
resolvedRequestId,
|
||||
"DENIED",
|
||||
userId,
|
||||
client
|
||||
);
|
||||
|
||||
await invitesModel.createGroupAuditLog(
|
||||
{
|
||||
groupId: resolvedGroupId,
|
||||
actorUserId: userId,
|
||||
actorRole,
|
||||
eventType: "GROUP_JOIN_REQUEST_DENIED",
|
||||
requestId: requestIdForAudit,
|
||||
ip: normalizeIp(ip),
|
||||
userAgent: userAgent || null,
|
||||
metadata: {
|
||||
joinRequestId: deniedRequest.id,
|
||||
targetUserId: deniedRequest.user_id,
|
||||
},
|
||||
},
|
||||
client
|
||||
);
|
||||
|
||||
return deniedRequest;
|
||||
});
|
||||
}
|
||||
|
||||
function getInviteStatus(link) {
|
||||
const now = Date.now();
|
||||
if (link.single_use && link.used_at) return "USED";
|
||||
@ -546,9 +662,11 @@ module.exports = {
|
||||
JOIN_RESULTS,
|
||||
acceptInviteLink,
|
||||
createInviteLink,
|
||||
decideJoinRequest,
|
||||
deleteInviteLink,
|
||||
getGroupJoinPolicy,
|
||||
getInviteLinkSummaryByToken,
|
||||
listPendingJoinRequests,
|
||||
listInviteLinks,
|
||||
resolveManagedGroupId,
|
||||
revokeInviteLink,
|
||||
|
||||
@ -12,8 +12,10 @@ jest.mock("../services/group-invites.service", () => {
|
||||
acceptInviteLink: jest.fn(),
|
||||
createInviteLink: jest.fn(),
|
||||
deleteInviteLink: jest.fn(),
|
||||
decideJoinRequest: jest.fn(),
|
||||
getGroupJoinPolicy: jest.fn(),
|
||||
getInviteLinkSummaryByToken: jest.fn(),
|
||||
listPendingJoinRequests: jest.fn(),
|
||||
listInviteLinks: jest.fn(),
|
||||
resolveManagedGroupId: jest.fn(),
|
||||
revokeInviteLink: jest.fn(),
|
||||
@ -28,8 +30,10 @@ const app = require("../app");
|
||||
|
||||
describe("group invites routes", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
invitesService.resolveManagedGroupId.mockResolvedValue(1);
|
||||
invitesService.listInviteLinks.mockResolvedValue([]);
|
||||
invitesService.listPendingJoinRequests.mockResolvedValue([]);
|
||||
invitesService.createInviteLink.mockResolvedValue({
|
||||
id: 1,
|
||||
token: "abcd",
|
||||
@ -71,4 +75,36 @@ describe("group invites routes", () => {
|
||||
expect(response.body.request_id).toBeTruthy();
|
||||
expect(response.body.link).toBeTruthy();
|
||||
});
|
||||
|
||||
test("pending join requests can be listed with request_id", async () => {
|
||||
invitesService.listPendingJoinRequests.mockResolvedValue([
|
||||
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
|
||||
]);
|
||||
|
||||
const response = await request(app).get("/api/groups/join-requests");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.request_id).toBeTruthy();
|
||||
expect(response.body.requests).toEqual([
|
||||
{ id: 12, user_id: 77, username: "pending-user", status: "PENDING" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("decision route maps service validation errors", async () => {
|
||||
invitesService.decideJoinRequest.mockRejectedValue(
|
||||
new invitesService.InviteServiceError(
|
||||
"JOIN_REQUEST_NOT_FOUND",
|
||||
"Pending join request not found",
|
||||
404
|
||||
)
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post("/api/groups/join-requests/decision")
|
||||
.send({ requestId: 99, decision: "APPROVE" });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body.request_id).toBeTruthy();
|
||||
expect(response.body.error.code).toBe("JOIN_REQUEST_NOT_FOUND");
|
||||
});
|
||||
});
|
||||
|
||||
@ -10,12 +10,15 @@ jest.mock("../models/group-invites.model", () => ({
|
||||
getInviteLinkById: jest.fn(),
|
||||
getInviteLinkSummaryByToken: jest.fn(),
|
||||
getManageableGroupsForUser: jest.fn(),
|
||||
getPendingJoinRequestById: jest.fn(),
|
||||
getPendingJoinRequest: jest.fn(),
|
||||
getUserGroupRole: jest.fn(),
|
||||
isGroupMember: jest.fn(),
|
||||
listPendingJoinRequests: jest.fn(),
|
||||
listInviteLinks: jest.fn(),
|
||||
revokeInviteLink: jest.fn(),
|
||||
reviveInviteLink: jest.fn(),
|
||||
updateJoinRequestDecision: jest.fn(),
|
||||
upsertGroupSettings: jest.fn(),
|
||||
withTransaction: jest.fn(),
|
||||
}));
|
||||
@ -41,6 +44,7 @@ function inviteSummary(overrides = {}) {
|
||||
|
||||
describe("group invites service", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
|
||||
});
|
||||
|
||||
@ -186,4 +190,120 @@ describe("group invites service", () => {
|
||||
expect(result.status).toBe("PENDING");
|
||||
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("listPendingJoinRequests requires manager role and returns pending requests", async () => {
|
||||
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
||||
invitesModel.getUserGroupRole.mockResolvedValue("owner");
|
||||
invitesModel.listPendingJoinRequests.mockResolvedValue([
|
||||
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
|
||||
]);
|
||||
|
||||
const result = await invitesService.listPendingJoinRequests(99, 10);
|
||||
|
||||
expect(invitesModel.listPendingJoinRequests).toHaveBeenCalledWith(10);
|
||||
expect(result).toEqual([
|
||||
{ id: 12, user_id: 88, username: "pending-user", status: "PENDING" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("approve join request adds membership, updates request, and audits decision", async () => {
|
||||
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
||||
invitesModel.getUserGroupRole.mockResolvedValue("admin");
|
||||
invitesModel.getPendingJoinRequestById.mockResolvedValue({
|
||||
id: 77,
|
||||
group_id: 10,
|
||||
user_id: 55,
|
||||
username: "pending-user",
|
||||
status: "PENDING",
|
||||
});
|
||||
invitesModel.isGroupMember.mockResolvedValue(false);
|
||||
invitesModel.addGroupMember.mockResolvedValue(true);
|
||||
invitesModel.updateJoinRequestDecision.mockResolvedValue({
|
||||
id: 77,
|
||||
group_id: 10,
|
||||
user_id: 55,
|
||||
status: "APPROVED",
|
||||
decided_by: 99,
|
||||
});
|
||||
|
||||
const result = await invitesService.decideJoinRequest(
|
||||
99,
|
||||
10,
|
||||
77,
|
||||
"APPROVE",
|
||||
"req-approve",
|
||||
"127.0.0.1",
|
||||
"ua"
|
||||
);
|
||||
|
||||
expect(invitesModel.getPendingJoinRequestById).toHaveBeenCalledWith(
|
||||
10,
|
||||
77,
|
||||
{},
|
||||
true
|
||||
);
|
||||
expect(invitesModel.addGroupMember).toHaveBeenCalledWith(10, 55, "member", {});
|
||||
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
|
||||
10,
|
||||
77,
|
||||
"APPROVED",
|
||||
99,
|
||||
{}
|
||||
);
|
||||
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
|
||||
eventType: "GROUP_JOIN_REQUEST_APPROVED",
|
||||
requestId: "req-approve",
|
||||
metadata: {
|
||||
joinRequestId: 77,
|
||||
targetUserId: 55,
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe("APPROVED");
|
||||
});
|
||||
|
||||
test("deny join request updates request and audits decision", async () => {
|
||||
invitesModel.getGroupById.mockResolvedValue({ id: 10, name: "Test Group" });
|
||||
invitesModel.getUserGroupRole.mockResolvedValue("owner");
|
||||
invitesModel.getPendingJoinRequestById.mockResolvedValue({
|
||||
id: 78,
|
||||
group_id: 10,
|
||||
user_id: 56,
|
||||
status: "PENDING",
|
||||
});
|
||||
invitesModel.updateJoinRequestDecision.mockResolvedValue({
|
||||
id: 78,
|
||||
group_id: 10,
|
||||
user_id: 56,
|
||||
status: "DENIED",
|
||||
decided_by: 99,
|
||||
});
|
||||
|
||||
const result = await invitesService.decideJoinRequest(
|
||||
99,
|
||||
10,
|
||||
78,
|
||||
"DENY",
|
||||
"req-deny",
|
||||
"127.0.0.1",
|
||||
"ua"
|
||||
);
|
||||
|
||||
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
|
||||
expect(invitesModel.updateJoinRequestDecision).toHaveBeenCalledWith(
|
||||
10,
|
||||
78,
|
||||
"DENIED",
|
||||
99,
|
||||
{}
|
||||
);
|
||||
expect(invitesModel.createGroupAuditLog.mock.calls[0][0]).toMatchObject({
|
||||
eventType: "GROUP_JOIN_REQUEST_DENIED",
|
||||
requestId: "req-deny",
|
||||
metadata: {
|
||||
joinRequestId: 78,
|
||||
targetUserId: 56,
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe("DENIED");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user