diff --git a/backend/controllers/households.controller.js b/backend/controllers/households.controller.js index 49f0e53..03390b3 100644 --- a/backend/controllers/households.controller.js +++ b/backend/controllers/households.controller.js @@ -14,6 +14,42 @@ exports.getUserHouseholds = async (req, res) => { } }; +exports.reorderHouseholds = async (req, res) => { + try { + const rawHouseholdIds = req.body.household_ids || req.body.householdIds; + + if (!Array.isArray(rawHouseholdIds)) { + return sendError(res, 400, "household_ids must be an array"); + } + + const householdIds = rawHouseholdIds.map((householdId) => + Number.parseInt(householdId, 10) + ); + const hasInvalidId = householdIds.some( + (householdId) => !Number.isInteger(householdId) || householdId <= 0 + ); + const hasDuplicates = new Set(householdIds).size !== householdIds.length; + + if (hasInvalidId || hasDuplicates) { + return sendError(res, 400, "household_ids must contain unique positive household IDs"); + } + + const households = await householdModel.reorderUserHouseholds(req.user.id, householdIds); + + if (!households) { + return sendError(res, 400, "Household order must include every household you belong to"); + } + + res.json({ + message: "Household order updated successfully", + households, + }); + } catch (error) { + logError(req, "households.reorderHouseholds", error); + sendError(res, 500, "Failed to update household order"); + } +}; + // Get household details exports.getHousehold = async (req, res) => { try { diff --git a/backend/models/household.model.js b/backend/models/household.model.js index db146d5..5c568d7 100644 --- a/backend/models/household.model.js +++ b/backend/models/household.model.js @@ -1,8 +1,7 @@ const pool = require("../db/pool"); -// Get all households a user belongs to -exports.getUserHouseholds = async (userId) => { - const result = await pool.query( +async function queryUserHouseholds(db, userId) { + const result = await db.query( `SELECT h.id, h.name, @@ -10,14 +9,65 @@ exports.getUserHouseholds = async (userId) => { h.created_at, hm.role, hm.joined_at, + hm.household_sort_order, (SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count FROM households h JOIN household_members hm ON h.id = hm.household_id WHERE hm.user_id = $1 - ORDER BY hm.joined_at DESC`, + ORDER BY hm.household_sort_order ASC NULLS LAST, hm.joined_at DESC`, [userId] ); return result.rows; +} + +// Get all households a user belongs to +exports.getUserHouseholds = async (userId) => { + return queryUserHouseholds(pool, userId); +}; + +exports.reorderUserHouseholds = async (userId, householdIds) => { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + const membershipResult = await client.query( + `SELECT household_id + FROM household_members + WHERE user_id = $1`, + [userId] + ); + + const currentIds = membershipResult.rows.map((row) => Number(row.household_id)); + const currentIdSet = new Set(currentIds); + + if ( + householdIds.length !== currentIds.length || + householdIds.some((householdId) => !currentIdSet.has(householdId)) + ) { + await client.query("ROLLBACK"); + return null; + } + + for (const [index, householdId] of householdIds.entries()) { + await client.query( + `UPDATE household_members + SET household_sort_order = $1 + WHERE user_id = $2 + AND household_id = $3`, + [index, userId, householdId] + ); + } + + const households = await queryUserHouseholds(client, userId); + await client.query("COMMIT"); + return households; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } }; // Get household by ID (with member check) diff --git a/backend/routes/households.routes.js b/backend/routes/households.routes.js index 1dd1a73..970d647 100644 --- a/backend/routes/households.routes.js +++ b/backend/routes/households.routes.js @@ -16,6 +16,7 @@ const { upload, processImage } = require("../middleware/image"); // Public routes (authenticated only) router.get("/", auth, controller.getUserHouseholds); router.post("/", auth, controller.createHousehold); +router.patch("/order", auth, controller.reorderHouseholds); router.post("/join/:inviteCode", auth, controller.joinHousehold); // Household-scoped routes (member access required) diff --git a/backend/tests/available-items.routes.test.js b/backend/tests/available-items.routes.test.js index 5a18381..0e50ae9 100644 --- a/backend/tests/available-items.routes.test.js +++ b/backend/tests/available-items.routes.test.js @@ -43,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({ joinHousehold: jest.fn(), refreshInviteCode: jest.fn(), removeMember: jest.fn(), + reorderHouseholds: jest.fn(), updateHousehold: jest.fn(), updateMemberRole: jest.fn(), })); diff --git a/backend/tests/household.model.test.js b/backend/tests/household.model.test.js new file mode 100644 index 0000000..835fbfe --- /dev/null +++ b/backend/tests/household.model.test.js @@ -0,0 +1,75 @@ +jest.mock("../db/pool", () => ({ + connect: jest.fn(), + query: jest.fn(), +})); + +const pool = require("../db/pool"); +const Household = require("../models/household.model"); + +describe("household.model household ordering", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("loads households using the user's saved sort order", async () => { + pool.query.mockResolvedValueOnce({ + rows: [{ id: 2, name: "Second", household_sort_order: 0 }], + }); + + const households = await Household.getUserHouseholds(9); + + expect(households).toEqual([{ id: 2, name: "Second", household_sort_order: 0 }]); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining("ORDER BY hm.household_sort_order ASC NULLS LAST"), + [9] + ); + }); + + test("persists a full household order for the current user", async () => { + const client = { + query: jest.fn() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ rows: [{ household_id: 1 }, { household_id: 2 }] }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ rows: [{ id: 2 }, { id: 1 }] }) + .mockResolvedValueOnce({}), + release: jest.fn(), + }; + pool.connect.mockResolvedValueOnce(client); + + const households = await Household.reorderUserHouseholds(9, [2, 1]); + + expect(households).toEqual([{ id: 2 }, { id: 1 }]); + expect(client.query).toHaveBeenNthCalledWith(1, "BEGIN"); + expect(client.query).toHaveBeenNthCalledWith( + 3, + expect.stringContaining("SET household_sort_order = $1"), + [0, 9, 2] + ); + expect(client.query).toHaveBeenNthCalledWith( + 4, + expect.stringContaining("SET household_sort_order = $1"), + [1, 9, 1] + ); + expect(client.query).toHaveBeenLastCalledWith("COMMIT"); + expect(client.release).toHaveBeenCalled(); + }); + + test("rejects an order that does not match the user's memberships", async () => { + const client = { + query: jest.fn() + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({ rows: [{ household_id: 1 }] }) + .mockResolvedValueOnce({}), + release: jest.fn(), + }; + pool.connect.mockResolvedValueOnce(client); + + const households = await Household.reorderUserHouseholds(9, [1, 2]); + + expect(households).toBeNull(); + expect(client.query).toHaveBeenNthCalledWith(3, "ROLLBACK"); + expect(client.release).toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/households.controller.test.js b/backend/tests/households.controller.test.js index 6ff47cb..2e0b645 100644 --- a/backend/tests/households.controller.test.js +++ b/backend/tests/households.controller.test.js @@ -1,5 +1,6 @@ jest.mock("../models/household.model", () => ({ getUserRole: jest.fn(), + reorderUserHouseholds: jest.fn(), transferOwnership: jest.fn(), updateMemberRole: jest.fn(), })); @@ -86,3 +87,72 @@ describe("households.controller updateMemberRole", () => { }); }); }); + +describe("households.controller reorderHouseholds", () => { + beforeEach(() => { + jest.clearAllMocks(); + householdModel.reorderUserHouseholds.mockResolvedValue([ + { id: 3, name: "Third" }, + { id: 1, name: "First" }, + ]); + }); + + test("updates the current user's household order", async () => { + const req = { + body: { household_ids: [3, 1] }, + user: { id: 9 }, + }; + const res = createResponse(); + + await controller.reorderHouseholds(req, res); + + expect(householdModel.reorderUserHouseholds).toHaveBeenCalledWith(9, [3, 1]); + expect(res.json).toHaveBeenCalledWith({ + message: "Household order updated successfully", + households: [ + { id: 3, name: "Third" }, + { id: 1, name: "First" }, + ], + }); + }); + + test("rejects duplicate household IDs", async () => { + const req = { + body: { household_ids: [3, 3] }, + user: { id: 9 }, + }; + const res = createResponse(); + + await controller.reorderHouseholds(req, res); + + expect(householdModel.reorderUserHouseholds).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "household_ids must contain unique positive household IDs", + }), + }) + ); + }); + + test("rejects orders that do not match current memberships", async () => { + householdModel.reorderUserHouseholds.mockResolvedValue(null); + const req = { + body: { household_ids: [999] }, + user: { id: 9 }, + }; + const res = createResponse(); + + await controller.reorderHouseholds(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.objectContaining({ + message: "Household order must include every household you belong to", + }), + }) + ); + }); +}); diff --git a/backend/tests/store-locations.routes.test.js b/backend/tests/store-locations.routes.test.js index db958b2..b331eae 100644 --- a/backend/tests/store-locations.routes.test.js +++ b/backend/tests/store-locations.routes.test.js @@ -43,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({ joinHousehold: jest.fn(), refreshInviteCode: jest.fn(), removeMember: jest.fn(), + reorderHouseholds: jest.fn((req, res) => res.json({ message: "ordered" })), updateHousehold: jest.fn(), updateMemberRole: jest.fn(), })); @@ -87,6 +88,7 @@ jest.mock("../controllers/stores.controller", () => ({ const express = require("express"); const request = require("supertest"); const router = require("../routes/households.routes"); +const householdsController = require("../controllers/households.controller"); const storesController = require("../controllers/stores.controller"); describe("store location routes", () => { @@ -106,6 +108,15 @@ describe("store location routes", () => { expect(storesController.getHouseholdStores).toHaveBeenCalled(); }); + test("users can reorder their household switcher list", async () => { + const response = await request(app) + .patch("/households/order") + .send({ household_ids: [3, 1, 2] }); + + expect(response.status).toBe(200); + expect(householdsController.reorderHouseholds).toHaveBeenCalled(); + }); + test("members cannot create household stores", async () => { const response = await request(app) .post("/households/1/stores") diff --git a/docs/guides/api-documentation.md b/docs/guides/api-documentation.md index 0f8eafb..99c14fb 100644 --- a/docs/guides/api-documentation.md +++ b/docs/guides/api-documentation.md @@ -28,6 +28,21 @@ The active grocery flow is scoped by household-owned store locations, not the le Owners/admins manage stores, locations, zones, and catalog deletion. Members can add/update list items and catalog item details. +### Household Switcher Order + +The current user can persist their own household switcher order: + +- Update household order: `PATCH /households/order` + +Request body: +```json +{ + "household_ids": [3, 1, 2] +} +``` + +The list must contain each household the current user belongs to exactly once. The response returns the reordered household list. + --- ## Authentication diff --git a/frontend/src/api/households.js b/frontend/src/api/households.js index 9ac78e4..e0d04f9 100644 --- a/frontend/src/api/households.js +++ b/frontend/src/api/households.js @@ -5,6 +5,12 @@ import api from "./axios"; */ export const getUserHouseholds = () => api.get("/households"); +/** + * Update the current user's household switcher order + */ +export const reorderHouseholds = (householdIds) => + api.patch("/households/order", { household_ids: householdIds }); + /** * Get details of a specific household */ diff --git a/frontend/src/components/household/HouseholdSwitcher.jsx b/frontend/src/components/household/HouseholdSwitcher.jsx index 8a01b27..2f22683 100644 --- a/frontend/src/components/household/HouseholdSwitcher.jsx +++ b/frontend/src/components/household/HouseholdSwitcher.jsx @@ -1,5 +1,7 @@ import { useContext, useState } from "react"; import { HouseholdContext } from "../../context/HouseholdContext"; +import useActionToast from "../../hooks/useActionToast"; +import getApiErrorMessage from "../../lib/getApiErrorMessage"; import "../../styles/components/HouseholdSwitcher.css"; import CreateJoinHousehold from "../manage/CreateJoinHousehold"; @@ -8,10 +10,13 @@ export default function HouseholdSwitcher() { households, activeHousehold, setActiveHousehold, + reorderHouseholds, loading, hasLoaded, } = useContext(HouseholdContext); + const toast = useActionToast(); const [isOpen, setIsOpen] = useState(false); + const [isReordering, setIsReordering] = useState(false); const [showCreateJoin, setShowCreateJoin] = useState(false); if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { @@ -49,6 +54,28 @@ export default function HouseholdSwitcher() { setIsOpen(false); }; + const handleMove = async (household, currentIndex, direction) => { + const nextIndex = currentIndex + direction; + if (nextIndex < 0 || nextIndex >= households.length || isReordering) return; + + const nextHouseholds = [...households]; + [nextHouseholds[currentIndex], nextHouseholds[nextIndex]] = [ + nextHouseholds[nextIndex], + nextHouseholds[currentIndex], + ]; + + setIsReordering(true); + try { + await reorderHouseholds(nextHouseholds.map((nextHousehold) => nextHousehold.id)); + toast.success("Updated household order", `Moved ${household.name}`); + } catch (error) { + const message = getApiErrorMessage(error, "Failed to update household order"); + toast.error("Household order failed", `Household order failed: ${message}`); + } finally { + setIsReordering(false); + } + }; + return (