diff --git a/backend/controllers/group-invites.controller.js b/backend/controllers/group-invites.controller.js index e840edd..7807b8a 100644 --- a/backend/controllers/group-invites.controller.js +++ b/backend/controllers/group-invites.controller.js @@ -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); diff --git a/backend/models/group-invites.model.js b/backend/models/group-invites.model.js index 058def0..9a2f888 100644 --- a/backend/models/group-invites.model.js +++ b/backend/models/group-invites.model.js @@ -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, }; diff --git a/backend/routes/group-invites.routes.js b/backend/routes/group-invites.routes.js index 1fa7ef6..a05908d 100644 --- a/backend/routes/group-invites.routes.js +++ b/backend/routes/group-invites.routes.js @@ -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, diff --git a/backend/services/group-invites.service.js b/backend/services/group-invites.service.js index c944c4a..69eb68c 100644 --- a/backend/services/group-invites.service.js +++ b/backend/services/group-invites.service.js @@ -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, diff --git a/backend/tests/group-invites.routes.test.js b/backend/tests/group-invites.routes.test.js index 662eb82..a8b86df 100644 --- a/backend/tests/group-invites.routes.test.js +++ b/backend/tests/group-invites.routes.test.js @@ -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"); + }); }); diff --git a/backend/tests/group-invites.service.test.js b/backend/tests/group-invites.service.test.js index 6674506..c66d4d7 100644 --- a/backend/tests/group-invites.service.test.js +++ b/backend/tests/group-invites.service.test.js @@ -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"); + }); });