diff --git a/backend/controllers/households.controller.js b/backend/controllers/households.controller.js index eeb0619..49f0e53 100644 --- a/backend/controllers/households.controller.js +++ b/backend/controllers/households.controller.js @@ -165,8 +165,8 @@ exports.updateMemberRole = async (req, res) => { const { userId } = req.params; const { role } = req.body; - if (!role || !['admin', 'member'].includes(role)) { - return sendError(res, 400, "Invalid role. Must be 'admin' or 'member'"); + if (!role || !["owner", "admin", "member"].includes(role)) { + return sendError(res, 400, "Invalid role. Must be 'owner', 'admin', or 'member'"); } // Can't change own role @@ -182,14 +182,29 @@ exports.updateMemberRole = async (req, res) => { return sendError(res, 403, "Owner role cannot be changed"); } - const updated = await householdModel.updateMemberRole( - req.params.householdId, - userId, - role - ); + let updated; + if (role === "owner") { + if (req.household.role !== "owner") { + return sendError(res, 403, "Only the household owner can transfer ownership"); + } + + updated = await householdModel.transferOwnership( + req.params.householdId, + req.user.id, + parseInt(userId, 10) + ); + } else { + updated = await householdModel.updateMemberRole( + req.params.householdId, + userId, + role + ); + } res.json({ - message: "Member role updated successfully", + message: role === "owner" + ? "Household ownership transferred successfully" + : "Member role updated successfully", member: updated }); } catch (error) { diff --git a/backend/models/household.model.js b/backend/models/household.model.js index 080dc3d..528ce89 100644 --- a/backend/models/household.model.js +++ b/backend/models/household.model.js @@ -162,6 +162,47 @@ exports.updateMemberRole = async (householdId, userId, newRole) => { return result.rows[0]; }; +// Transfer household ownership from one member to another atomically. +exports.transferOwnership = async (householdId, currentOwnerUserId, nextOwnerUserId) => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const promoteResult = await client.query( + `UPDATE household_members + SET role = 'owner' + WHERE household_id = $1 AND user_id = $2 + RETURNING user_id, role`, + [householdId, nextOwnerUserId] + ); + + if (promoteResult.rows.length === 0) { + throw new Error("TARGET_MEMBER_NOT_FOUND"); + } + + const demoteResult = await client.query( + `UPDATE household_members + SET role = 'admin' + WHERE household_id = $1 AND user_id = $2 + RETURNING user_id, role`, + [householdId, currentOwnerUserId] + ); + + if (demoteResult.rows.length === 0) { + throw new Error("CURRENT_OWNER_NOT_FOUND"); + } + + await client.query("COMMIT"); + return promoteResult.rows[0]; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + // Remove member from household exports.removeMember = async (householdId, userId) => { await pool.query( diff --git a/backend/tests/households.controller.test.js b/backend/tests/households.controller.test.js new file mode 100644 index 0000000..6ff47cb --- /dev/null +++ b/backend/tests/households.controller.test.js @@ -0,0 +1,88 @@ +jest.mock("../models/household.model", () => ({ + getUserRole: jest.fn(), + transferOwnership: jest.fn(), + updateMemberRole: jest.fn(), +})); + +jest.mock("../utils/logger", () => ({ + logError: jest.fn(), +})); + +const householdModel = require("../models/household.model"); +const controller = require("../controllers/households.controller"); + +function createResponse() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe("households.controller updateMemberRole", () => { + beforeEach(() => { + jest.clearAllMocks(); + householdModel.getUserRole.mockResolvedValue("member"); + householdModel.transferOwnership.mockResolvedValue({ user_id: 7, role: "owner" }); + householdModel.updateMemberRole.mockResolvedValue({ user_id: 7, role: "admin" }); + }); + + test("owner can transfer household ownership", async () => { + const req = { + params: { householdId: "3", userId: "7" }, + body: { role: "owner" }, + user: { id: 1 }, + household: { id: 3, role: "owner" }, + }; + const res = createResponse(); + + await controller.updateMemberRole(req, res); + + expect(householdModel.transferOwnership).toHaveBeenCalledWith("3", 1, 7); + expect(householdModel.updateMemberRole).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + message: "Household ownership transferred successfully", + member: { user_id: 7, role: "owner" }, + }); + }); + + test("admin cannot transfer household ownership", async () => { + const req = { + params: { householdId: "3", userId: "7" }, + body: { role: "owner" }, + user: { id: 1 }, + household: { id: 3, role: "admin" }, + }; + const res = createResponse(); + + await controller.updateMemberRole(req, res); + + expect(householdModel.transferOwnership).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Only the household owner can transfer ownership", + }), + }) + ); + }); + + test("owner can still update a member to admin without transfer flow", async () => { + const req = { + params: { householdId: "3", userId: "7" }, + body: { role: "admin" }, + user: { id: 1 }, + household: { id: 3, role: "owner" }, + }; + const res = createResponse(); + + await controller.updateMemberRole(req, res); + + expect(householdModel.updateMemberRole).toHaveBeenCalledWith("3", "7", "admin"); + expect(householdModel.transferOwnership).not.toHaveBeenCalled(); + expect(res.json).toHaveBeenCalledWith({ + message: "Member role updated successfully", + member: { user_id: 7, role: "admin" }, + }); + }); +}); diff --git a/frontend/src/components/manage/ManageHousehold.jsx b/frontend/src/components/manage/ManageHousehold.jsx index 01cedb6..488cf71 100644 --- a/frontend/src/components/manage/ManageHousehold.jsx +++ b/frontend/src/components/manage/ManageHousehold.jsx @@ -71,6 +71,7 @@ export default function ManageHousehold() { const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false); const isManager = ["owner", "admin"].includes(activeHousehold?.role); + const isOwner = activeHousehold?.role === "owner"; const isMemberOnly = activeHousehold?.role === "member"; useEffect(() => { @@ -276,14 +277,27 @@ export default function ManageHousehold() { } }; - const handleUpdateRole = async (memberId, currentRole, memberName) => { - if (currentRole === "owner") return; - const newRole = currentRole === "admin" ? "member" : "admin"; + const handleUpdateRole = async (memberId, nextRole, memberName) => { + if (!nextRole) return; + + if ( + nextRole === "owner" && + !window.confirm(`Make ${memberName} the household owner? You will become an admin.`) + ) { + return; + } try { - await updateMemberRole(activeHousehold.id, memberId, newRole); - await loadMembers(); - toast.success("Updated member role", `Updated role for ${memberName} to ${newRole}`); + await updateMemberRole(activeHousehold.id, memberId, nextRole); + await Promise.all([ + loadMembers(), + nextRole === "owner" ? refreshHouseholds() : Promise.resolve(), + ]); + if (nextRole === "owner") { + toast.success("Transferred household ownership", `Transferred ownership to ${memberName}`); + } else { + toast.success("Updated member role", `Updated role for ${memberName} to ${nextRole}`); + } } catch (error) { const message = getApiErrorMessage(error, "Failed to update member role"); toast.error("Update member role failed", `Update member role failed: ${message}`); @@ -560,8 +574,20 @@ export default function ManageHousehold() { {isManager && !isSelf && member.role !== "owner" && (
+ {isOwner && ( + + )}