feature-custom-store-locations #4

Merged
nalalangan merged 16 commits from feature-custom-store-locations into main 2026-05-31 00:35:29 -09:00
13 changed files with 472 additions and 24 deletions
Showing only changes of commit 0f01da92b6 - Show all commits

View File

@ -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 // Get household details
exports.getHousehold = async (req, res) => { exports.getHousehold = async (req, res) => {
try { try {

View File

@ -1,8 +1,7 @@
const pool = require("../db/pool"); const pool = require("../db/pool");
// Get all households a user belongs to async function queryUserHouseholds(db, userId) {
exports.getUserHouseholds = async (userId) => { const result = await db.query(
const result = await pool.query(
`SELECT `SELECT
h.id, h.id,
h.name, h.name,
@ -10,14 +9,65 @@ exports.getUserHouseholds = async (userId) => {
h.created_at, h.created_at,
hm.role, hm.role,
hm.joined_at, hm.joined_at,
hm.household_sort_order,
(SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count (SELECT COUNT(*) FROM household_members WHERE household_id = h.id) as member_count
FROM households h FROM households h
JOIN household_members hm ON h.id = hm.household_id JOIN household_members hm ON h.id = hm.household_id
WHERE hm.user_id = $1 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] [userId]
); );
return result.rows; 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) // Get household by ID (with member check)

View File

@ -16,6 +16,7 @@ const { upload, processImage } = require("../middleware/image");
// Public routes (authenticated only) // Public routes (authenticated only)
router.get("/", auth, controller.getUserHouseholds); router.get("/", auth, controller.getUserHouseholds);
router.post("/", auth, controller.createHousehold); router.post("/", auth, controller.createHousehold);
router.patch("/order", auth, controller.reorderHouseholds);
router.post("/join/:inviteCode", auth, controller.joinHousehold); router.post("/join/:inviteCode", auth, controller.joinHousehold);
// Household-scoped routes (member access required) // Household-scoped routes (member access required)

View File

@ -43,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({
joinHousehold: jest.fn(), joinHousehold: jest.fn(),
refreshInviteCode: jest.fn(), refreshInviteCode: jest.fn(),
removeMember: jest.fn(), removeMember: jest.fn(),
reorderHouseholds: jest.fn(),
updateHousehold: jest.fn(), updateHousehold: jest.fn(),
updateMemberRole: jest.fn(), updateMemberRole: jest.fn(),
})); }));

View File

@ -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();
});
});

View File

@ -1,5 +1,6 @@
jest.mock("../models/household.model", () => ({ jest.mock("../models/household.model", () => ({
getUserRole: jest.fn(), getUserRole: jest.fn(),
reorderUserHouseholds: jest.fn(),
transferOwnership: jest.fn(), transferOwnership: jest.fn(),
updateMemberRole: 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",
}),
})
);
});
});

View File

@ -43,6 +43,7 @@ jest.mock("../controllers/households.controller", () => ({
joinHousehold: jest.fn(), joinHousehold: jest.fn(),
refreshInviteCode: jest.fn(), refreshInviteCode: jest.fn(),
removeMember: jest.fn(), removeMember: jest.fn(),
reorderHouseholds: jest.fn((req, res) => res.json({ message: "ordered" })),
updateHousehold: jest.fn(), updateHousehold: jest.fn(),
updateMemberRole: jest.fn(), updateMemberRole: jest.fn(),
})); }));
@ -87,6 +88,7 @@ jest.mock("../controllers/stores.controller", () => ({
const express = require("express"); const express = require("express");
const request = require("supertest"); const request = require("supertest");
const router = require("../routes/households.routes"); const router = require("../routes/households.routes");
const householdsController = require("../controllers/households.controller");
const storesController = require("../controllers/stores.controller"); const storesController = require("../controllers/stores.controller");
describe("store location routes", () => { describe("store location routes", () => {
@ -106,6 +108,15 @@ describe("store location routes", () => {
expect(storesController.getHouseholdStores).toHaveBeenCalled(); 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 () => { test("members cannot create household stores", async () => {
const response = await request(app) const response = await request(app)
.post("/households/1/stores") .post("/households/1/stores")

View File

@ -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. 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 ## Authentication

View File

@ -5,6 +5,12 @@ import api from "./axios";
*/ */
export const getUserHouseholds = () => api.get("/households"); 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 * Get details of a specific household
*/ */

View File

@ -1,5 +1,7 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/HouseholdSwitcher.css"; import "../../styles/components/HouseholdSwitcher.css";
import CreateJoinHousehold from "../manage/CreateJoinHousehold"; import CreateJoinHousehold from "../manage/CreateJoinHousehold";
@ -8,10 +10,13 @@ export default function HouseholdSwitcher() {
households, households,
activeHousehold, activeHousehold,
setActiveHousehold, setActiveHousehold,
reorderHouseholds,
loading, loading,
hasLoaded, hasLoaded,
} = useContext(HouseholdContext); } = useContext(HouseholdContext);
const toast = useActionToast();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isReordering, setIsReordering] = useState(false);
const [showCreateJoin, setShowCreateJoin] = useState(false); const [showCreateJoin, setShowCreateJoin] = useState(false);
if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) { if (!hasLoaded || loading || (households.length > 0 && !activeHousehold)) {
@ -49,6 +54,28 @@ export default function HouseholdSwitcher() {
setIsOpen(false); 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 ( return (
<div className="household-switcher"> <div className="household-switcher">
<button <button
@ -65,18 +92,51 @@ export default function HouseholdSwitcher() {
<> <>
<div className="household-switcher-overlay" onClick={() => setIsOpen(false)} /> <div className="household-switcher-overlay" onClick={() => setIsOpen(false)} />
<div className="household-switcher-dropdown"> <div className="household-switcher-dropdown">
{households.map((household) => ( {households.map((household, index) => (
<button <div
key={household.id} key={household.id}
className={`household-option ${household.id === activeHousehold.id ? "active" : ""}`} className={
type="button" `household-option-row ${household.id === activeHousehold.id ? "active" : ""}`
onClick={() => handleSelect(household)} }
> >
{household.name} <button
{household.id === activeHousehold.id && ( className="household-option"
<span className="check-mark">&#10003;</span> type="button"
onClick={() => handleSelect(household)}
>
<span className="household-option-name">{household.name}</span>
{household.id === activeHousehold.id && (
<span className="check-mark">&#10003;</span>
)}
</button>
{households.length > 1 && (
<div
className="household-reorder-controls"
aria-label={`Reorder ${household.name}`}
>
<button
className="household-reorder-button"
type="button"
onClick={() => handleMove(household, index, -1)}
disabled={index === 0 || isReordering}
aria-label={`Move ${household.name} up`}
title={`Move ${household.name} up`}
>
&#9650;
</button>
<button
className="household-reorder-button"
type="button"
onClick={() => handleMove(household, index, 1)}
disabled={index === households.length - 1 || isReordering}
aria-label={`Move ${household.name} down`}
title={`Move ${household.name} down`}
>
&#9660;
</button>
</div>
)} )}
</button> </div>
))} ))}
<div className="household-divider"></div> <div className="household-divider"></div>
<button <button

View File

@ -1,5 +1,9 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'; import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { createHousehold as createHouseholdApi, getUserHouseholds } from '../api/households'; import {
createHousehold as createHouseholdApi,
getUserHouseholds,
reorderHouseholds as reorderHouseholdsApi,
} from '../api/households';
import { isTransientApiError } from '../api/offlineCache'; import { isTransientApiError } from '../api/offlineCache';
import { AuthContext } from './AuthContext'; import { AuthContext } from './AuthContext';
@ -14,6 +18,7 @@ export const HouseholdContext = createContext({
setActiveHousehold: () => { }, setActiveHousehold: () => { },
refreshHouseholds: () => { }, refreshHouseholds: () => { },
createHousehold: () => { }, createHousehold: () => { },
reorderHouseholds: () => { },
}); });
export const HouseholdProvider = ({ children }) => { export const HouseholdProvider = ({ children }) => {
@ -121,6 +126,57 @@ export const HouseholdProvider = ({ children }) => {
} }
}; };
const reorderHouseholds = async (orderedHouseholdIds) => {
const householdById = new Map(
households.map((household) => [String(household.id), household])
);
const nextHouseholds = orderedHouseholdIds
.map((householdId) => householdById.get(String(householdId)))
.filter(Boolean);
if (nextHouseholds.length !== households.length) {
throw new Error("Household order is out of date");
}
const previousHouseholds = households;
setHouseholds(nextHouseholds);
if (activeHousehold) {
const nextActiveHousehold = nextHouseholds.find(
(household) => String(household.id) === String(activeHousehold.id)
);
if (nextActiveHousehold) {
setActiveHouseholdState(nextActiveHousehold);
}
}
try {
const response = await reorderHouseholdsApi(orderedHouseholdIds);
const savedHouseholds = Array.isArray(response.data.households)
? response.data.households
: [];
if (savedHouseholds.length > 0) {
setHouseholds(savedHouseholds);
if (activeHousehold) {
const savedActiveHousehold = savedHouseholds.find(
(household) => String(household.id) === String(activeHousehold.id)
);
if (savedActiveHousehold) {
setActiveHouseholdState(savedActiveHousehold);
}
}
}
return savedHouseholds;
} catch (err) {
setHouseholds(previousHouseholds);
if (activeHousehold) {
setActiveHouseholdState(activeHousehold);
}
throw err;
}
};
const value = { const value = {
households, households,
activeHousehold, activeHousehold,
@ -130,6 +186,7 @@ export const HouseholdProvider = ({ children }) => {
setActiveHousehold, setActiveHousehold,
refreshHouseholds: loadHouseholds, refreshHouseholds: loadHouseholds,
createHousehold, createHousehold,
reorderHouseholds,
}; };
return ( return (

View File

@ -70,8 +70,10 @@
position: absolute; position: absolute;
top: calc(100% + 0.5rem); top: calc(100% + 0.5rem);
left: 0; left: 0;
right: 0; right: auto;
width: 100%; min-width: 280px;
width: max-content;
max-width: min(360px, calc(100vw - 2rem));
overflow: hidden; overflow: hidden;
background: var(--card-bg); background: var(--card-bg);
border: 2px solid var(--border); border: 2px solid var(--border);
@ -80,15 +82,32 @@
z-index: 1000; z-index: 1000;
} }
.household-option-row {
display: flex;
align-items: stretch;
min-height: 48px;
background: var(--card-bg);
border-bottom: 1px solid var(--border);
}
.household-option-row:hover {
background: var(--button-secondary-bg);
}
.household-option-row.active {
background: color-mix(in srgb, var(--primary) 15%, transparent);
}
.household-option { .household-option {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex: 1;
min-width: 0;
width: 100%; width: 100%;
padding: 0.875rem 1rem; padding: 0.875rem 1rem;
background: var(--card-bg); background: transparent;
border: none; border: none;
border-bottom: 1px solid var(--border);
color: var(--text-primary); color: var(--text-primary);
font-size: 1rem; font-size: 1rem;
text-align: left; text-align: left;
@ -96,22 +115,59 @@
transition: all 0.2s ease; transition: all 0.2s ease;
} }
.household-option:last-child { .household-option-name {
border-bottom: none; min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.household-option:hover { .household-option:hover {
background: var(--button-secondary-bg); color: var(--primary);
border-color: var(--primary);
} }
.household-option.active { .household-option-row.active .household-option {
background: color-mix(in srgb, var(--primary) 15%, transparent);
color: var(--primary); color: var(--primary);
font-weight: 600; font-weight: 600;
} }
.household-reorder-controls {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 0.25rem;
padding: 0.375rem 0.5rem 0.375rem 0;
}
.household-reorder-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
background: transparent;
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
}
.household-reorder-button:hover:not(:disabled),
.household-reorder-button:focus-visible {
border-color: var(--primary);
color: var(--primary);
outline: none;
}
.household-reorder-button:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.check-mark { .check-mark {
flex-shrink: 0;
margin-left: 0.75rem;
color: var(--primary); color: var(--primary);
font-size: 1.1rem; font-size: 1.1rem;
font-weight: bold; font-weight: bold;
@ -124,6 +180,7 @@
} }
.create-household-btn { .create-household-btn {
border-bottom: none;
color: var(--primary); color: var(--primary);
font-weight: 600; font-weight: 600;
} }

View File

@ -0,0 +1,9 @@
BEGIN;
ALTER TABLE household_members
ADD COLUMN IF NOT EXISTS household_sort_order INTEGER;
CREATE INDEX IF NOT EXISTS idx_household_members_user_sort_order
ON household_members(user_id, household_sort_order, joined_at DESC);
COMMIT;