refactor
All checks were successful
Build & Deploy Costco Grocery List / build (push) Successful in 1m10s
Build & Deploy Costco Grocery List / verify-images (push) Successful in 3s
Build & Deploy Costco Grocery List / deploy (push) Successful in 11s
Build & Deploy Costco Grocery List / notify (push) Successful in 1s

This commit is contained in:
Nico 2026-02-22 01:27:03 -08:00
parent ee94853084
commit 77ae5be445
74 changed files with 4562 additions and 355 deletions

4
.gitignore vendored
View File

@ -7,6 +7,10 @@ node_modules/
# Build output (if using a bundler or React later) # Build output (if using a bundler or React later)
dist/ dist/
build/ build/
playwright-report/
test-results/
.npm-cache/
.playwright-browsers/
# Logs # Logs
npm-debug.log* npm-debug.log*

View File

@ -0,0 +1 @@
[]

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,12 @@
2026-02-19 01:30:31.762 [error] Error: Unable to create or open registry key
at Object.setDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\storage.js:82:25)
at Module.getDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\devdeviceid.js:46:23)
at async U9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11451)
at async F9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11886)
at async e7.f (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:50696)
at async e7.run (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:49285)
at async Module.t7 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:53186)
at async kn (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/cli.js:29:16018)
2026-02-19 01:30:31.767 [info] CLI main {"_":[],"diff":false,"merge":false,"add":false,"remove":false,"goto":false,"new-window":false,"reuse-window":false,"wait":false,"user-data-dir":".vscode-user","help":false,"extensions-dir":".vscode-extensions","list-extensions":true,"show-versions":false,"pre-release":false,"update-extensions":false,"version":false,"verbose":false,"status":false,"prof-startup":false,"no-cached-data":false,"prof-v8-extensions":false,"disable-extensions":false,"disable-lcd-text":false,"disable-gpu":false,"disable-chromium-sandbox":false,"sandbox":false,"telemetry":false,"debugRenderer":false,"enable-smoke-test-driver":false,"logExtensionHostCommunication":false,"skip-release-notes":false,"skip-welcome":false,"disable-telemetry":false,"disable-updates":false,"transient":false,"use-inmemory-secretstorage":false,"disable-workspace-trust":false,"disable-crash-reporter":false,"skip-add-to-recently-opened":false,"open-url":false,"file-write":false,"file-chmod":false,"force":false,"do-not-sync":false,"do-not-include-pack-dependencies":false,"trace":false,"trace-memory-infra":false,"preserve-env":false,"force-user-env":false,"force-disable-user-env":false,"open-devtools":false,"disable-gpu-sandbox":false,"__enable-file-policy":false,"enable-coi":false,"enable-rdp-display-tracking":false,"disable-layout-restore":false,"disable-experiments":false,"no-proxy-server":false,"no-sandbox":false,"nolazy":false,"force-renderer-accessibility":false,"ignore-certificate-errors":false,"allow-insecure-localhost":false,"disable-dev-shm-usage":false,"profile-temp":false,"logsPath":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user\\logs\\20260219T013031"}
2026-02-19 01:30:31.783 [info] Started initializing default profile extensions in extensions installation folder. file:///c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions
2026-02-19 01:30:31.817 [info] Completed initializing default profile extensions in extensions installation folder. file:///c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions

View File

@ -0,0 +1,14 @@
2026-02-19 01:30:39.254 [error] Error: Unable to create or open registry key
at Object.setDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\storage.js:82:25)
at Module.getDeviceId (C:\Users\Nico\AppData\Local\Programs\Microsoft VS Code\591199df40\resources\app\node_modules\@vscode\deviceid\dist\devdeviceid.js:46:23)
at async U9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11451)
at async F9 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:11886)
at async e7.f (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:50696)
at async e7.run (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:49285)
at async Module.t7 (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/vs/code/node/cliProcessMain.js:85:53186)
at async kn (file:///C:/Users/Nico/AppData/Local/Programs/Microsoft%20VS%20Code/591199df40/resources/app/out/cli.js:29:16018)
2026-02-19 01:30:39.259 [info] CLI main {"_":[],"diff":false,"merge":false,"add":false,"remove":false,"goto":false,"new-window":false,"reuse-window":false,"wait":false,"user-data-dir":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user","help":false,"extensions-dir":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-extensions","list-extensions":false,"show-versions":false,"install-extension":["ritwickdey.LiveServer"],"pre-release":false,"update-extensions":false,"version":false,"verbose":false,"status":false,"prof-startup":false,"no-cached-data":false,"prof-v8-extensions":false,"disable-extensions":false,"disable-lcd-text":false,"disable-gpu":false,"disable-chromium-sandbox":false,"sandbox":false,"telemetry":false,"debugRenderer":false,"enable-smoke-test-driver":false,"logExtensionHostCommunication":false,"skip-release-notes":false,"skip-welcome":false,"disable-telemetry":false,"disable-updates":false,"transient":false,"use-inmemory-secretstorage":false,"disable-workspace-trust":false,"disable-crash-reporter":false,"skip-add-to-recently-opened":false,"open-url":false,"file-write":false,"file-chmod":false,"force":false,"do-not-sync":false,"do-not-include-pack-dependencies":false,"trace":false,"trace-memory-infra":false,"preserve-env":false,"force-user-env":false,"force-disable-user-env":false,"open-devtools":false,"disable-gpu-sandbox":false,"__enable-file-policy":false,"enable-coi":false,"enable-rdp-display-tracking":false,"disable-layout-restore":false,"disable-experiments":false,"no-proxy-server":false,"no-sandbox":false,"nolazy":false,"force-renderer-accessibility":false,"ignore-certificate-errors":false,"allow-insecure-localhost":false,"disable-dev-shm-usage":false,"profile-temp":false,"logsPath":"C:\\Users\\Nico\\Desktop\\Projects\\Costco-Grocery-List\\.vscode-user\\logs\\20260219T013038"}
2026-02-19 01:30:40.046 [info] Getting Manifest... ritwickdey.liveserver
2026-02-19 01:30:40.071 [info] Installing extension: ritwickdey.liveserver {"isMachineScoped":false,"installPreReleaseVersion":false,"donotIncludePackAndDependencies":false,"profileLocation":{"$mid":1,"external":"vscode-userdata:/c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json","path":"/C:/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json","scheme":"vscode-userdata"},"isBuiltin":false,"installGivenVersion":false,"isApplicationScoped":false,"productVersion":{"version":"1.109.2","date":"2026-02-10T20:18:23.520Z"}}
2026-02-19 01:30:40.581 [info] Extension signature verification result for ritwickdey.liveserver: UnknownError. Executed: false. Duration: 5ms.
2026-02-19 01:30:40.599 [error] Error while installing the extension ritwickdey.liveserver Signature verification failed with 'UnknownError' error. vscode-userdata:/c%3A/Users/Nico/Desktop/Projects/Costco-Grocery-List/.vscode-extensions/extensions.json

1
.vscode-user/machineid Normal file
View File

@ -0,0 +1 @@
490a70bb-d2b1-490e-9046-37c8a08b0270

View File

@ -21,6 +21,8 @@
- Receipt images stored in `receipts` (`bytea`). - Receipt images stored in `receipts` (`bytea`).
- Entries list endpoints must NEVER return receipt bytes. - Entries list endpoints must NEVER return receipt bytes.
- API responses must include `request_id`; audit logs must include `request_id`. - API responses must include `request_id`; audit logs must include `request_id`.
- Frontend actions that manipulate database state must show a toast/bubble notification with basic outcome info (action + target + success/failure).
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`).
## Architecture boundaries (follow existing patterns; do not invent) ## Architecture boundaries (follow existing patterns; do not invent)
1) API routes: `app/api/**/route.ts` 1) API routes: `app/api/**/route.ts`

View File

@ -154,7 +154,8 @@ For `app/api/**/[param]/route.ts`:
- Mouse: hover affordance on interactive rows/cards. - Mouse: hover affordance on interactive rows/cards.
- Tap targets remain >= 40px on mobile. - Tap targets remain >= 40px on mobile.
- Modal overlays must close on outside click/tap. - Modal overlays must close on outside click/tap.
- Use bubble notifications for main actions (create/update/delete/join). - For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
- Add Playwright UI tests for new UI features and critical flows. - Add Playwright UI tests for new UI features and critical flows.
--- ---

View File

@ -2,6 +2,8 @@ FROM node:20-alpine
WORKDIR /app WORKDIR /app
RUN apk add --no-cache postgresql-client
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install

View File

@ -63,6 +63,9 @@ app.use("/households", householdsRoutes);
const storesRoutes = require("./routes/stores.routes"); const storesRoutes = require("./routes/stores.routes");
app.use("/stores", storesRoutes); app.use("/stores", storesRoutes);
const groupInvitesRoutes = require("./routes/group-invites.routes");
app.use("/api", groupInvitesRoutes);
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
if (res.headersSent) { if (res.headersSent) {
return next(err); return next(err);

View File

@ -0,0 +1,216 @@
const invitesService = require("../services/group-invites.service");
const { sendError } = require("../utils/http");
const { logError } = require("../utils/logger");
const { inviteCodeLast4 } = require("../utils/redaction");
function getClientIp(req) {
const forwardedFor = req.headers["x-forwarded-for"];
if (typeof forwardedFor === "string" && forwardedFor.trim()) {
return forwardedFor.split(",")[0].trim();
}
return req.ip || req.socket?.remoteAddress || null;
}
function parseRequestedGroupId(req) {
const headerGroupId = req.headers["x-group-id"] || req.headers["x-household-id"];
if (headerGroupId) {
const raw = Array.isArray(headerGroupId) ? headerGroupId[0] : headerGroupId;
return raw;
}
if (req.query?.groupId !== undefined) {
return req.query.groupId;
}
if (req.body?.groupId !== undefined) {
return req.body.groupId;
}
return undefined;
}
function clampTtlDays(value) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) return 1;
return Math.max(1, Math.min(7, parsed));
}
function mapServiceError(req, res, error, context, extraLog = {}) {
if (error instanceof invitesService.InviteServiceError) {
return sendError(res, error.statusCode, error.message, error.code);
}
logError(req, context, error, extraLog);
return sendError(res, 500, "Failed to process invite request");
}
exports.listInviteLinks = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const links = await invitesService.listInviteLinks(req.user.id, groupId);
res.json({ links });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.listInviteLinks");
}
};
exports.createInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const ttlDays = clampTtlDays(req.body?.ttlDays);
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
const link = await invitesService.createInviteLink(
req.user.id,
groupId,
req.body?.policy,
Boolean(req.body?.singleUse),
expiresAt,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.status(201).json({ link });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.createInviteLink");
}
};
exports.revokeInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
await invitesService.revokeInviteLink(
req.user.id,
groupId,
req.body?.linkId,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ ok: true });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.revokeInviteLink");
}
};
exports.reviveInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const ttlDays = clampTtlDays(req.body?.ttlDays);
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000);
await invitesService.reviveInviteLink(
req.user.id,
groupId,
req.body?.linkId,
expiresAt,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ ok: true });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.reviveInviteLink");
}
};
exports.deleteInviteLink = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
await invitesService.deleteInviteLink(
req.user.id,
groupId,
req.body?.linkId,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ ok: true });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.deleteInviteLink");
}
};
exports.getJoinPolicy = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
const joinPolicy = await invitesService.getGroupJoinPolicy(req.user.id, groupId);
res.json({ joinPolicy });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.getJoinPolicy");
}
};
exports.setJoinPolicy = async (req, res) => {
try {
const requestedGroupId = parseRequestedGroupId(req);
const groupId = await invitesService.resolveManagedGroupId(
req.user.id,
requestedGroupId
);
await invitesService.setGroupJoinPolicy(
req.user.id,
groupId,
req.body?.joinPolicy,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ ok: true });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.setJoinPolicy");
}
};
exports.getInviteLinkSummary = async (req, res) => {
const token = req.params.token;
const inviteLast4 = inviteCodeLast4(token);
try {
const link = await invitesService.getInviteLinkSummaryByToken(
token,
req.user?.id || null
);
res.json({ link });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.getInviteLinkSummary", {
invite_last4: inviteLast4,
});
}
};
exports.acceptInviteLink = async (req, res) => {
const token = req.params.token;
const inviteLast4 = inviteCodeLast4(token);
try {
const result = await invitesService.acceptInviteLink(
req.user.id,
token,
req.request_id,
getClientIp(req),
req.headers["user-agent"] || null
);
res.json({ result });
} catch (error) {
return mapServiceError(req, res, error, "groupInvites.acceptInviteLink", {
invite_last4: inviteLast4,
});
}
};

View File

@ -165,8 +165,8 @@ exports.updateMemberRole = async (req, res) => {
const { userId } = req.params; const { userId } = req.params;
const { role } = req.body; const { role } = req.body;
if (!role || !['admin', 'user'].includes(role)) { if (!role || !['admin', 'member'].includes(role)) {
return sendError(res, 400, "Invalid role. Must be 'admin' or 'user'"); return sendError(res, 400, "Invalid role. Must be 'admin' or 'member'");
} }
// Can't change own role // Can't change own role
@ -174,6 +174,14 @@ exports.updateMemberRole = async (req, res) => {
return sendError(res, 400, "Cannot change your own role"); return sendError(res, 400, "Cannot change your own role");
} }
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
if (!targetRole) {
return sendError(res, 404, "Member not found");
}
if (targetRole === "owner") {
return sendError(res, 403, "Owner role cannot be changed");
}
const updated = await householdModel.updateMemberRole( const updated = await householdModel.updateMemberRole(
req.params.householdId, req.params.householdId,
userId, userId,
@ -197,8 +205,13 @@ exports.removeMember = async (req, res) => {
const targetUserId = parseInt(userId); const targetUserId = parseInt(userId);
// Allow users to remove themselves, or admins to remove others // Allow users to remove themselves, or admins to remove others
if (targetUserId !== req.user.id && req.household.role !== 'admin') { if (targetUserId !== req.user.id && !["owner", "admin"].includes(req.household.role)) {
return sendError(res, 403, "Only admins can remove other members"); return sendError(res, 403, "Only admins or owners can remove other members");
}
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
if (targetRole === "owner") {
return sendError(res, 403, "Owner cannot be removed");
} }
await householdModel.removeMember(req.params.householdId, userId); await householdModel.removeMember(req.params.householdId, userId);

View File

@ -60,7 +60,12 @@ exports.addItem = async (req, res) => {
} }
if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") { if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") {
const parsedUserId = Number.parseInt(String(added_for_user_id), 10); const rawAddedForUserId = String(added_for_user_id).trim();
if (!/^\d+$/.test(rawAddedForUserId)) {
return sendError(res, 400, "Added-for user ID must be a positive integer");
}
const parsedUserId = Number.parseInt(rawAddedForUserId, 10);
if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) { if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) {
return sendError(res, 400, "Added-for user ID must be a positive integer"); return sendError(res, 400, "Added-for user ID must be a positive integer");

View File

@ -54,8 +54,8 @@ exports.requireHouseholdRole = (...allowedRoles) => {
}; };
}; };
// Middleware to require admin role in household // Middleware to require admin/owner role in household
exports.requireHouseholdAdmin = exports.requireHouseholdRole('admin'); exports.requireHouseholdAdmin = exports.requireHouseholdRole('owner', 'admin');
// Middleware to check store access (household must have store) // Middleware to check store access (household must have store)
exports.storeAccess = async (req, res, next) => { exports.storeAccess = async (req, res, next) => {

View File

@ -0,0 +1,47 @@
const jwt = require("jsonwebtoken");
const Session = require("../models/session.model");
const { parseCookieHeader } = require("../utils/cookies");
const { cookieName } = require("../utils/session-cookie");
const { logError } = require("../utils/logger");
async function optionalAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : null;
if (token) {
const jwtSecret = process.env.JWT_SECRET;
if (!jwtSecret) {
return next();
}
try {
const decoded = jwt.verify(token, jwtSecret);
req.user = decoded;
return next();
} catch (err) {
return next();
}
}
try {
const cookies = parseCookieHeader(req.headers.cookie);
const sid = cookies[cookieName()];
if (!sid) return next();
const session = await Session.getActiveSessionWithUser(sid);
if (!session) return next();
req.user = {
id: session.user_id,
role: session.role,
username: session.username,
};
req.session_id = session.id;
} catch (err) {
logError(req, "middleware.optionalAuth", err);
}
return next();
}
module.exports = optionalAuth;

View File

@ -18,7 +18,7 @@ function getClientIp(req) {
return req.ip || req.socket?.remoteAddress || "unknown"; return req.ip || req.socket?.remoteAddress || "unknown";
} }
function createRateLimit({ keyPrefix, windowMs, max, message }) { function createRateLimit({ keyPrefix, windowMs, max, message, keyFn }) {
return (req, res, next) => { return (req, res, next) => {
const now = Date.now(); const now = Date.now();
@ -26,7 +26,8 @@ function createRateLimit({ keyPrefix, windowMs, max, message }) {
pruneExpired(now); pruneExpired(now);
} }
const key = `${keyPrefix}:${getClientIp(req)}`; const suffix = typeof keyFn === "function" ? keyFn(req) : getClientIp(req);
const key = `${keyPrefix}:${suffix || "unknown"}`;
const existing = buckets.get(key); const existing = buckets.get(key);
const bucket = const bucket =
!existing || existing.resetAt <= now !existing || existing.resetAt <= now

View File

@ -0,0 +1,65 @@
{
"generated_at": "2026-02-19T07:24:39.402Z",
"canonical_dir": "packages\\db\\migrations",
"legacy_dir": "backend\\migrations",
"stale_sql_files": [
{
"filename": "add_display_name_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f",
"canonical_sha256": "3df494bbbaf6cf3221e48dee763b66f7b4de0c4f5a43552e6f7350271e10a22f"
},
{
"filename": "add_image_columns.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a",
"canonical_sha256": "45e14112cc88661aea3c55c149bfbe08e692571851b8f9d5061624e9ec3c0d6a"
},
{
"filename": "add_modified_on_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b",
"canonical_sha256": "dfcaf14ade2241b240d5632e23e5b52b4361b4fc7fdfcaec950c33a9026b9f1b"
},
{
"filename": "add_notes_column.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a",
"canonical_sha256": "c2988c18d14adea5ab0693059b47b333b40be58223d9b607581f84853fcd1a1a"
},
{
"filename": "create_item_classification_table.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a",
"canonical_sha256": "2191db3870457050fbdd90e1a02fa1cdde9e6c34746a0c818ac6232a55f7937a"
},
{
"filename": "multi_household_architecture.sql",
"status": "STALE_DUPLICATE_OF_CANONICAL",
"backend_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e",
"canonical_sha256": "5cb427f188f8db8bf9b982e8b5ea9e44df67bc5e47f9aa2cf5e371df2d00610e"
}
],
"canonical_only_sql_files": [
{
"filename": "create_sessions_table.sql",
"status": "CANONICAL_ONLY",
"canonical_sha256": "d46e5147eb113042e9c2856d17b38715e66a486ee4d7c6450c960145791bc030"
},
{
"filename": "zz_group_invites_and_join_policies.sql",
"status": "CANONICAL_ONLY",
"canonical_sha256": "de955333667326f8eaf224431ecb62a5d0bd354fa0ccce34af6e52374e55d6e3"
}
],
"legacy_non_sql_files": [
"MIGRATION_GUIDE.md"
],
"summary": {
"stale_total": 6,
"stale_only_in_backend_total": 0,
"stale_duplicate_total": 6,
"stale_diverged_total": 0,
"canonical_only_total": 2
}
}

View File

@ -54,7 +54,7 @@ exports.createHousehold = async (name, createdBy) => {
// Add creator as admin // Add creator as admin
await pool.query( await pool.query(
`INSERT INTO household_members (household_id, user_id, role) `INSERT INTO household_members (household_id, user_id, role)
VALUES ($1, $2, 'admin')`, VALUES ($1, $2, 'owner')`,
[result.rows[0].id, createdBy] [result.rows[0].id, createdBy]
); );
@ -118,7 +118,7 @@ exports.joinHousehold = async (inviteCode, userId) => {
// Add as user role // Add as user role
await pool.query( await pool.query(
`INSERT INTO household_members (household_id, user_id, role) `INSERT INTO household_members (household_id, user_id, role)
VALUES ($1, $2, 'user')`, VALUES ($1, $2, 'member')`,
[household.id, userId] [household.id, userId]
); );
@ -140,8 +140,9 @@ exports.getHouseholdMembers = async (householdId) => {
WHERE hm.household_id = $1 WHERE hm.household_id = $1
ORDER BY ORDER BY
CASE hm.role CASE hm.role
WHEN 'admin' THEN 1 WHEN 'owner' THEN 1
WHEN 'user' THEN 2 WHEN 'admin' THEN 2
WHEN 'member' THEN 3
END, END,
hm.joined_at ASC`, hm.joined_at ASC`,
[householdId] [householdId]

View File

@ -0,0 +1,72 @@
const router = require("express").Router();
const auth = require("../middleware/auth");
const optionalAuth = require("../middleware/optional-auth");
const { createRateLimit } = require("../middleware/rate-limit");
const controller = require("../controllers/group-invites.controller");
const inviteSummaryIpRateLimit = createRateLimit({
keyPrefix: "invite:summary:ip",
windowMs: 15 * 60 * 1000,
max: 120,
message: "Too many invite link summary requests. Please try again later.",
});
const inviteAcceptIpRateLimit = createRateLimit({
keyPrefix: "invite:accept:ip",
windowMs: 15 * 60 * 1000,
max: 60,
message: "Too many invite acceptance attempts. Please try again later.",
});
const inviteWriteUserRateLimit = createRateLimit({
keyPrefix: "invite:write:user",
windowMs: 15 * 60 * 1000,
max: 60,
message: "Too many write operations. Please try again later.",
keyFn: (req) => (req.user?.id ? `user:${req.user.id}` : "anon"),
});
router.get("/groups/invites", auth, controller.listInviteLinks);
router.post("/groups/invites", auth, inviteWriteUserRateLimit, controller.createInviteLink);
router.post(
"/groups/invites/revoke",
auth,
inviteWriteUserRateLimit,
controller.revokeInviteLink
);
router.post(
"/groups/invites/revive",
auth,
inviteWriteUserRateLimit,
controller.reviveInviteLink
);
router.post(
"/groups/invites/delete",
auth,
inviteWriteUserRateLimit,
controller.deleteInviteLink
);
router.get("/groups/join-policy", auth, controller.getJoinPolicy);
router.post(
"/groups/join-policy",
auth,
inviteWriteUserRateLimit,
controller.setJoinPolicy
);
router.get(
"/invite-links/:token",
inviteSummaryIpRateLimit,
optionalAuth,
controller.getInviteLinkSummary
);
router.post(
"/invite-links/:token",
auth,
inviteAcceptIpRateLimit,
inviteWriteUserRateLimit,
controller.acceptInviteLink
);
module.exports = router;

View File

@ -0,0 +1,557 @@
const crypto = require("crypto");
const net = require("net");
const invitesModel = require("../models/group-invites.model");
const { inviteCodeLast4 } = require("../utils/redaction");
const JOIN_POLICIES = Object.freeze({
NOT_ACCEPTING: "NOT_ACCEPTING",
AUTO_ACCEPT: "AUTO_ACCEPT",
APPROVAL_REQUIRED: "APPROVAL_REQUIRED",
});
const JOIN_RESULTS = Object.freeze({
JOINED: "JOINED",
PENDING: "PENDING",
ALREADY_MEMBER: "ALREADY_MEMBER",
});
class InviteServiceError extends Error {
constructor(code, message, statusCode = 400) {
super(message);
this.name = "InviteServiceError";
this.code = code;
this.statusCode = statusCode;
}
}
function normalizeIp(ip) {
if (!ip || typeof ip !== "string") return null;
const trimmed = ip.trim();
if (!trimmed) return null;
return net.isIP(trimmed) ? trimmed : null;
}
function ensureJoinPolicy(policy) {
if (Object.values(JOIN_POLICIES).includes(policy)) {
return policy;
}
throw new InviteServiceError(
"INVALID_JOIN_POLICY",
"Invalid join policy",
400
);
}
function ensurePositiveInteger(value, fieldName) {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new InviteServiceError("INVALID_INPUT", `${fieldName} is required`, 400);
}
return parsed;
}
function ensureDate(value, fieldName) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
throw new InviteServiceError("INVALID_INPUT", `${fieldName} is invalid`, 400);
}
return date;
}
async function ensureGroupAndManagerRole(userId, groupId, client) {
const group = await invitesModel.getGroupById(groupId, client);
if (!group) {
throw new InviteServiceError("GROUP_NOT_FOUND", "Group not found", 404);
}
const actorRole = await invitesModel.getUserGroupRole(groupId, userId, client);
if (!["owner", "admin"].includes(actorRole)) {
throw new InviteServiceError(
"FORBIDDEN",
"Admin or owner role required",
403
);
}
return { actorRole, group };
}
async function resolveManagedGroupId(userId, requestedGroupId) {
if (requestedGroupId !== undefined && requestedGroupId !== null) {
return ensurePositiveInteger(requestedGroupId, "groupId");
}
const manageableGroups = await invitesModel.getManageableGroupsForUser(userId);
if (manageableGroups.length === 0) {
throw new InviteServiceError(
"FORBIDDEN",
"Admin or owner role required",
403
);
}
if (manageableGroups.length > 1) {
throw new InviteServiceError(
"GROUP_ID_REQUIRED",
"Group ID is required when you manage multiple groups",
400
);
}
return manageableGroups[0].group_id;
}
async function createInviteLink(
userId,
groupId,
policy,
singleUse,
expiresAt,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedPolicy = ensureJoinPolicy(policy);
const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt");
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
let link = null;
for (let attempt = 0; attempt < 5; attempt += 1) {
const token = crypto.randomBytes(16).toString("hex");
try {
link = await invitesModel.createInviteLink(
{
groupId: resolvedGroupId,
createdBy: userId,
token,
policy: resolvedPolicy,
singleUse: Boolean(singleUse),
expiresAt: resolvedExpiresAt,
},
client
);
break;
} catch (error) {
if (error.code !== "23505") {
throw error;
}
}
}
if (!link) {
throw new InviteServiceError(
"INVITE_CREATE_FAILED",
"Unable to create invite link",
500
);
}
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_CREATED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(link.token),
},
},
client
);
return link;
});
}
async function listInviteLinks(userId, groupId) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
await ensureGroupAndManagerRole(userId, resolvedGroupId);
return invitesModel.listInviteLinks(resolvedGroupId);
}
async function revokeInviteLink(
userId,
groupId,
linkId,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const link = await invitesModel.revokeInviteLink(
resolvedGroupId,
resolvedLinkId,
client
);
if (!link) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_REVOKED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(link.token),
},
},
client
);
});
}
async function reviveInviteLink(
userId,
groupId,
linkId,
expiresAt,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
const resolvedExpiresAt = ensureDate(expiresAt, "expiresAt");
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const link = await invitesModel.reviveInviteLink(
resolvedGroupId,
resolvedLinkId,
resolvedExpiresAt,
client
);
if (!link) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_REVIVED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(link.token),
},
},
client
);
});
}
async function deleteInviteLink(
userId,
groupId,
linkId,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedLinkId = ensurePositiveInteger(linkId, "linkId");
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
const link = await invitesModel.deleteInviteLink(
resolvedGroupId,
resolvedLinkId,
client
);
if (!link) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_DELETED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(link.token),
},
},
client
);
});
}
function getInviteStatus(link) {
const now = Date.now();
if (link.single_use && link.used_at) return "USED";
if (link.revoked_at) return "REVOKED";
if (new Date(link.expires_at).getTime() <= now) return "EXPIRED";
return "ACTIVE";
}
async function getInviteLinkSummaryByToken(token, userId = null) {
if (!token || typeof token !== "string") {
throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400);
}
const summary = await invitesModel.getInviteLinkSummaryByToken(token.trim());
if (!summary) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
let viewerStatus = null;
if (userId) {
const isMember = await invitesModel.isGroupMember(summary.group_id, userId);
if (isMember) {
viewerStatus = JOIN_RESULTS.ALREADY_MEMBER;
} else {
const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId);
if (pending) {
viewerStatus = JOIN_RESULTS.PENDING;
}
}
}
const activePolicy = summary.current_join_policy || summary.policy;
return {
id: summary.id,
group_id: summary.group_id,
group_name: summary.group_name,
token: summary.token,
policy: summary.policy,
current_join_policy: summary.current_join_policy || null,
active_policy: activePolicy,
single_use: summary.single_use,
expires_at: summary.expires_at,
used_at: summary.used_at,
revoked_at: summary.revoked_at,
created_at: summary.created_at,
status: getInviteStatus(summary),
...(viewerStatus ? { viewerStatus } : {}),
};
}
async function acceptInviteLink(userId, token, requestId, ip, userAgent) {
if (!userId) {
throw new InviteServiceError("UNAUTHORIZED", "Authentication required", 401);
}
if (!token || typeof token !== "string") {
throw new InviteServiceError("INVALID_INVITE_TOKEN", "Invite token is required", 400);
}
return invitesModel.withTransaction(async (client) => {
const summary = await invitesModel.getInviteLinkSummaryByToken(
token.trim(),
client,
true
);
if (!summary) {
throw new InviteServiceError("INVITE_NOT_FOUND", "Invite link not found", 404);
}
const group = {
id: summary.group_id,
name: summary.group_name,
};
const memberExists = await invitesModel.isGroupMember(summary.group_id, userId, client);
if (memberExists) {
return { status: JOIN_RESULTS.ALREADY_MEMBER, group };
}
const pending = await invitesModel.getPendingJoinRequest(summary.group_id, userId, client);
if (pending) {
return { status: JOIN_RESULTS.PENDING, group };
}
const now = Date.now();
if (summary.revoked_at) {
throw new InviteServiceError(
"INVITE_REVOKED",
"This invite link has been revoked",
410
);
}
if (new Date(summary.expires_at).getTime() <= now) {
throw new InviteServiceError(
"INVITE_EXPIRED",
"This invite link has expired",
410
);
}
if (summary.single_use && summary.used_at) {
throw new InviteServiceError(
"INVITE_USED",
"This invite link has already been used",
410
);
}
const activePolicy =
summary.current_join_policy || summary.policy || JOIN_POLICIES.NOT_ACCEPTING;
if (activePolicy === JOIN_POLICIES.NOT_ACCEPTING) {
throw new InviteServiceError(
"JOIN_NOT_ACCEPTING",
"This group is not accepting new members",
403
);
}
const actorRole = (await invitesModel.getUserGroupRole(summary.group_id, userId, client)) || "guest";
if (activePolicy === JOIN_POLICIES.AUTO_ACCEPT) {
const inserted = await invitesModel.addGroupMember(
summary.group_id,
userId,
"member",
client
);
if (!inserted) {
return { status: JOIN_RESULTS.ALREADY_MEMBER, group };
}
if (summary.single_use) {
await invitesModel.consumeSingleUseInvite(summary.id, client);
}
await invitesModel.createGroupAuditLog(
{
groupId: summary.group_id,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_USED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(summary.token),
},
},
client
);
return { status: JOIN_RESULTS.JOINED, group };
}
if (activePolicy === JOIN_POLICIES.APPROVAL_REQUIRED) {
await invitesModel.createOrTouchPendingJoinRequest(summary.group_id, userId, client);
if (summary.single_use) {
await invitesModel.consumeSingleUseInvite(summary.id, client);
}
await invitesModel.createGroupAuditLog(
{
groupId: summary.group_id,
actorUserId: userId,
actorRole,
eventType: "GROUP_INVITE_REQUESTED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
inviteCodeLast4: inviteCodeLast4(summary.token),
},
},
client
);
return { status: JOIN_RESULTS.PENDING, group };
}
throw new InviteServiceError("INVALID_JOIN_POLICY", "Invalid join policy", 400);
});
}
async function getGroupJoinPolicy(userId, groupId) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
await ensureGroupAndManagerRole(userId, resolvedGroupId);
const settings = await invitesModel.getGroupSettings(resolvedGroupId);
return settings?.join_policy || JOIN_POLICIES.NOT_ACCEPTING;
}
async function setGroupJoinPolicy(
userId,
groupId,
joinPolicy,
requestId,
ip,
userAgent
) {
const resolvedGroupId = ensurePositiveInteger(groupId, "groupId");
const resolvedJoinPolicy = ensureJoinPolicy(joinPolicy);
return invitesModel.withTransaction(async (client) => {
const { actorRole } = await ensureGroupAndManagerRole(
userId,
resolvedGroupId,
client
);
await invitesModel.upsertGroupSettings(resolvedGroupId, resolvedJoinPolicy, client);
await invitesModel.createGroupAuditLog(
{
groupId: resolvedGroupId,
actorUserId: userId,
actorRole,
eventType: "GROUP_JOIN_POLICY_UPDATED",
requestId,
ip: normalizeIp(ip),
userAgent: userAgent || null,
metadata: {
joinPolicy: resolvedJoinPolicy,
},
},
client
);
});
}
module.exports = {
InviteServiceError,
JOIN_POLICIES,
JOIN_RESULTS,
acceptInviteLink,
createInviteLink,
deleteInviteLink,
getGroupJoinPolicy,
getInviteLinkSummaryByToken,
listInviteLinks,
resolveManagedGroupId,
revokeInviteLink,
reviveInviteLink,
setGroupJoinPolicy,
};

View File

@ -0,0 +1,74 @@
jest.mock("../middleware/auth", () => (req, res, next) => {
req.user = { id: 42, role: "user" };
next();
});
jest.mock("../middleware/optional-auth", () => (req, res, next) => next());
jest.mock("../services/group-invites.service", () => {
const actual = jest.requireActual("../services/group-invites.service");
return {
...actual,
acceptInviteLink: jest.fn(),
createInviteLink: jest.fn(),
deleteInviteLink: jest.fn(),
getGroupJoinPolicy: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(),
listInviteLinks: jest.fn(),
resolveManagedGroupId: jest.fn(),
revokeInviteLink: jest.fn(),
reviveInviteLink: jest.fn(),
setGroupJoinPolicy: jest.fn(),
};
});
const request = require("supertest");
const invitesService = require("../services/group-invites.service");
const app = require("../app");
describe("group invites routes", () => {
beforeEach(() => {
invitesService.resolveManagedGroupId.mockResolvedValue(1);
invitesService.listInviteLinks.mockResolvedValue([]);
invitesService.createInviteLink.mockResolvedValue({
id: 1,
token: "abcd",
status: "ACTIVE",
});
invitesService.getInviteLinkSummaryByToken.mockResolvedValue({
id: 1,
token: "abcd",
group_name: "Test Group",
status: "ACTIVE",
active_policy: "AUTO_ACCEPT",
});
});
test("admin-only checks are enforced on invite management routes", async () => {
invitesService.createInviteLink.mockRejectedValue(
new invitesService.InviteServiceError(
"FORBIDDEN",
"Admin or owner role required",
403
)
);
const response = await request(app).post("/api/groups/invites").send({
policy: "AUTO_ACCEPT",
singleUse: false,
ttlDays: 3,
});
expect(response.status).toBe(403);
expect(response.body.error.code).toBe("FORBIDDEN");
expect(response.body.request_id).toBeTruthy();
});
test("request_id is present in invite responses", async () => {
const response = await request(app).get("/api/invite-links/abcd1234");
expect(response.status).toBe(200);
expect(response.body.request_id).toBeTruthy();
expect(response.body.link).toBeTruthy();
});
});

View File

@ -0,0 +1,189 @@
jest.mock("../models/group-invites.model", () => ({
addGroupMember: jest.fn(),
createGroupAuditLog: jest.fn(),
createInviteLink: jest.fn(),
createOrTouchPendingJoinRequest: jest.fn(),
consumeSingleUseInvite: jest.fn(),
deleteInviteLink: jest.fn(),
getGroupById: jest.fn(),
getGroupSettings: jest.fn(),
getInviteLinkById: jest.fn(),
getInviteLinkSummaryByToken: jest.fn(),
getManageableGroupsForUser: jest.fn(),
getPendingJoinRequest: jest.fn(),
getUserGroupRole: jest.fn(),
isGroupMember: jest.fn(),
listInviteLinks: jest.fn(),
revokeInviteLink: jest.fn(),
reviveInviteLink: jest.fn(),
upsertGroupSettings: jest.fn(),
withTransaction: jest.fn(),
}));
const invitesModel = require("../models/group-invites.model");
const invitesService = require("../services/group-invites.service");
function inviteSummary(overrides = {}) {
return {
id: 30,
group_id: 10,
group_name: "Test Group",
token: "1234567890abcdef1234567890fedcba",
policy: "AUTO_ACCEPT",
current_join_policy: "AUTO_ACCEPT",
single_use: false,
expires_at: "2030-01-01T00:00:00.000Z",
used_at: null,
revoked_at: null,
...overrides,
};
}
describe("group invites service", () => {
beforeEach(() => {
invitesModel.withTransaction.mockImplementation(async (handler) => handler({}));
});
test("create link success writes audit with request_id and token last4 only", async () => {
invitesModel.getGroupById.mockResolvedValue({ id: 1, name: "G1" });
invitesModel.getUserGroupRole.mockResolvedValue("admin");
invitesModel.createInviteLink.mockResolvedValue({
id: 55,
group_id: 1,
token: "1234567890abcdef1234567890fedcba",
policy: "AUTO_ACCEPT",
single_use: true,
expires_at: "2030-01-01T00:00:00.000Z",
created_at: "2026-01-01T00:00:00.000Z",
});
const link = await invitesService.createInviteLink(
7,
1,
"AUTO_ACCEPT",
true,
"2030-01-01T00:00:00.000Z",
"req-123",
"127.0.0.1",
"ua"
);
expect(link.id).toBe(55);
expect(invitesModel.createGroupAuditLog).toHaveBeenCalledTimes(1);
const auditPayload = invitesModel.createGroupAuditLog.mock.calls[0][0];
expect(auditPayload.requestId).toBe("req-123");
expect(auditPayload.metadata).toEqual({ inviteCodeLast4: "dcba" });
});
test("accept auto-accept adds membership", async () => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
invitesModel.getUserGroupRole.mockResolvedValue(null);
invitesModel.addGroupMember.mockResolvedValue(true);
const result = await invitesService.acceptInviteLink(
99,
"token-1",
"req-1",
"127.0.0.1",
"ua"
);
expect(result).toEqual({
status: "JOINED",
group: { id: 10, name: "Test Group" },
});
expect(invitesModel.addGroupMember).toHaveBeenCalled();
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
"GROUP_INVITE_USED"
);
});
test("accept manual policy creates pending request", async () => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(
inviteSummary({
current_join_policy: "APPROVAL_REQUIRED",
single_use: true,
})
);
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
invitesModel.getUserGroupRole.mockResolvedValue(null);
invitesModel.createOrTouchPendingJoinRequest.mockResolvedValue({
id: 1,
status: "PENDING",
});
const result = await invitesService.acceptInviteLink(
99,
"token-2",
"req-2",
"127.0.0.1",
"ua"
);
expect(result).toEqual({
status: "PENDING",
group: { id: 10, name: "Test Group" },
});
expect(invitesModel.createOrTouchPendingJoinRequest).toHaveBeenCalled();
expect(invitesModel.consumeSingleUseInvite).toHaveBeenCalledWith(30, {});
expect(invitesModel.createGroupAuditLog.mock.calls[0][0].eventType).toBe(
"GROUP_INVITE_REQUESTED"
);
});
test.each([
["INVITE_EXPIRED", inviteSummary({ expires_at: "2020-01-01T00:00:00.000Z" })],
["INVITE_REVOKED", inviteSummary({ revoked_at: "2026-01-01T00:00:00.000Z" })],
[
"INVITE_USED",
inviteSummary({ single_use: true, used_at: "2026-01-01T00:00:00.000Z" }),
],
])("rejects %s links", async (expectedCode, summary) => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(summary);
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.getPendingJoinRequest.mockResolvedValue(null);
await expect(
invitesService.acceptInviteLink(99, "token-3", "req-3", "127.0.0.1", "ua")
).rejects.toMatchObject({ code: expectedCode });
});
test("accept returns ALREADY_MEMBER before pending checks", async () => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
invitesModel.isGroupMember.mockResolvedValue(true);
const result = await invitesService.acceptInviteLink(
99,
"token-4",
"req-4",
"127.0.0.1",
"ua"
);
expect(result.status).toBe("ALREADY_MEMBER");
expect(invitesModel.getPendingJoinRequest).not.toHaveBeenCalled();
});
test("accept returns PENDING when request already exists", async () => {
invitesModel.getInviteLinkSummaryByToken.mockResolvedValue(inviteSummary());
invitesModel.isGroupMember.mockResolvedValue(false);
invitesModel.getPendingJoinRequest.mockResolvedValue({
id: 5,
status: "PENDING",
});
const result = await invitesService.acceptInviteLink(
99,
"token-5",
"req-5",
"127.0.0.1",
"ua"
);
expect(result.status).toBe("PENDING");
expect(invitesModel.addGroupMember).not.toHaveBeenCalled();
});
});

View File

@ -50,6 +50,40 @@ describe("lists.controller.v2 addItem", () => {
expect(res.status).not.toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(400);
}); });
test("records history using request user when added_for_user_id is not provided", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1" },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
expect(List.addOrUpdateItem).toHaveBeenCalled();
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("records history using request user when added_for_user_id is blank", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1", added_for_user_id: " " },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
expect(List.addOrUpdateItem).toHaveBeenCalled();
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
expect(res.status).not.toHaveBeenCalledWith(400);
});
test("rejects invalid added_for_user_id", async () => { test("rejects invalid added_for_user_id", async () => {
const req = { const req = {
params: { householdId: "1", storeId: "2" }, params: { householdId: "1", storeId: "2" },
@ -73,6 +107,29 @@ describe("lists.controller.v2 addItem", () => {
); );
}); });
test("rejects malformed numeric-looking added_for_user_id", async () => {
const req = {
params: { householdId: "1", storeId: "2" },
body: { item_name: "milk", quantity: "1", added_for_user_id: "9abc" },
user: { id: 7 },
processedImage: null,
};
const res = createResponse();
await controller.addItem(req, res);
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
expect(List.addHistoryRecord).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
error: expect.objectContaining({
message: "Added-for user ID must be a positive integer",
}),
})
);
});
test("rejects added_for_user_id when target user is not household member", async () => { test("rejects added_for_user_id when target user is not household member", async () => {
householdModel.isHouseholdMember.mockResolvedValue(false); householdModel.isHouseholdMember.mockResolvedValue(false);

6
debug.log Normal file
View File

@ -0,0 +1,6 @@
[0219/013019.369:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/013019.648:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/013030.696:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/013038.475:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/013103.277:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)
[0219/014227.547:ERROR:third_party\crashpad\crashpad\util\win\registration_protocol_win.cc:108] CreateFile: Access is denied. (0x5)

View File

@ -1,8 +1,5 @@
#!/bin/bash #!/bin/bash
# Quick script to rebuild Docker Compose dev environment set -euo pipefail
echo "Stopping containers and removing volumes..." SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
docker-compose -f docker-compose.dev.yml down -v exec "$SCRIPT_DIR/rebuild-dev.sh" "$@"
echo "Rebuilding and starting containers..."
docker-compose -f docker-compose.dev.yml up --build

View File

@ -16,10 +16,19 @@ This project uses an external on-prem Postgres database. Migration files are can
- `npm run db:migrate:status` - `npm run db:migrate:status`
- Fail if pending migrations exist: - Fail if pending migrations exist:
- `npm run db:migrate:verify` - `npm run db:migrate:verify`
- Create a new migration file:
- `npm run db:migrate:new -- <migration-name>`
- Track stale legacy SQL in `backend/migrations`:
- `npm run db:migrate:stale`
- Fail when stale legacy SQL exists:
- `npm run db:migrate:stale:check`
## Active migration set ## Active migration set
Migration files are applied in lexicographic filename order from `packages/db/migrations`. Migration files are applied in lexicographic filename order from `packages/db/migrations`.
`backend/migrations` is legacy reference-only and not part of canonical execution.
`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored.
Current baseline files: Current baseline files:
- `add_display_name_column.sql` - `add_display_name_column.sql`
- `add_image_columns.sql` - `add_image_columns.sql`
@ -37,9 +46,11 @@ Applied migrations are recorded in:
## Expected operator flow ## Expected operator flow
1. Check status: 1. Check status:
- `npm run db:migrate:status` - `npm run db:migrate:status`
2. Apply pending: 2. If a new implementation needs schema changes, create a new file:
- `npm run db:migrate:new -- <migration-name>`
3. Apply pending:
- `npm run db:migrate` - `npm run db:migrate`
3. Verify clean state: 4. Verify clean state:
- `npm run db:migrate:verify` - `npm run db:migrate:verify`
## Troubleshooting ## Troubleshooting
@ -49,3 +60,8 @@ Applied migrations are recorded in:
- Install PostgreSQL client tools and retry. - Install PostgreSQL client tools and retry.
- SQL failure: - SQL failure:
- Fix migration SQL and rerun; only successful files are recorded in `schema_migrations`. - Fix migration SQL and rerun; only successful files are recorded in `schema_migrations`.
- Skip known stale SQL files for a specific environment:
- Set `DB_MIGRATE_SKIP_FILES` to a comma-separated filename list.
- Example: `DB_MIGRATE_SKIP_FILES=add_modified_on_column.sql,add_image_columns.sql`
- Temporarily include files listed in `stale-files.json`:
- Set `DB_MIGRATE_INCLUDE_STALE=true` before running migration commands.

View File

@ -15,6 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@playwright/test": "^1.52.0",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.2", "@types/react-dom": "^19.2.2",
@ -981,6 +982,21 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
"dev": true,
"dependencies": {
"playwright": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": { "node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.47", "version": "1.0.0-beta.47",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
@ -2915,6 +2931,50 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
"dev": true,
"dependencies": {
"playwright-core": "1.58.2"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.2",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",

View File

@ -7,7 +7,10 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test:e2e": "playwright test",
"test:e2e:headed": "playwright test --headed",
"test:e2e:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.2", "axios": "^1.13.2",
@ -16,6 +19,7 @@
"react-router-dom": "^7.9.6" "react-router-dom": "^7.9.6"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.52.0",
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",

View File

@ -0,0 +1,29 @@
import { defineConfig } from "@playwright/test";
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3010";
export default defineConfig({
testDir: "./tests",
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: [["list"], ["html", { open: "never" }]],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [
{
name: "chrome",
use: { browserName: "chromium", channel: "chrome" },
},
],
webServer: {
command: "npm run dev -- --host localhost --port 3010",
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
},
});

View File

@ -1,8 +1,10 @@
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ROLES } from "./constants/roles"; import { ROLES } from "./constants/roles";
import { AuthProvider } from "./context/AuthContext.jsx"; import { AuthProvider } from "./context/AuthContext.jsx";
import { ActionToastProvider } from "./context/ActionToastContext.jsx";
import { ConfigProvider } from "./context/ConfigContext.jsx"; import { ConfigProvider } from "./context/ConfigContext.jsx";
import { HouseholdProvider } from "./context/HouseholdContext.jsx"; import { HouseholdProvider } from "./context/HouseholdContext.jsx";
import { UploadQueueProvider } from "./context/UploadQueueContext.jsx";
import { SettingsProvider } from "./context/SettingsContext.jsx"; import { SettingsProvider } from "./context/SettingsContext.jsx";
import { StoreProvider } from "./context/StoreContext.jsx"; import { StoreProvider } from "./context/StoreContext.jsx";
@ -12,52 +14,56 @@ import Login from "./pages/Login.jsx";
import Manage from "./pages/Manage.jsx"; import Manage from "./pages/Manage.jsx";
import Register from "./pages/Register.jsx"; import Register from "./pages/Register.jsx";
import Settings from "./pages/Settings.jsx"; import Settings from "./pages/Settings.jsx";
import InviteLink from "./pages/InviteLink.jsx";
import AppLayout from "./components/layout/AppLayout.jsx"; import AppLayout from "./components/layout/AppLayout.jsx";
import UploadToaster from "./components/common/UploadToaster.jsx";
import PrivateRoute from "./utils/PrivateRoute.jsx"; import PrivateRoute from "./utils/PrivateRoute.jsx";
import RoleGuard from "./utils/RoleGuard.jsx"; import RoleGuard from "./utils/RoleGuard.jsx";
function App() { function App() {
return ( return (
<ConfigProvider> <ConfigProvider>
<AuthProvider> <AuthProvider>
<HouseholdProvider> <HouseholdProvider>
<StoreProvider> <StoreProvider>
<SettingsProvider> <UploadQueueProvider>
<BrowserRouter> <ActionToastProvider>
<Routes> <SettingsProvider>
<BrowserRouter>
<Routes>
{/* Public route */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/invite/:token" element={<InviteLink />} />
{/* Public route */} {/* Private routes with layout */}
<Route path="/login" element={<Login />} /> <Route
<Route path="/register" element={<Register />} /> element={
<PrivateRoute>
<AppLayout />
</PrivateRoute>
}
>
<Route path="/" element={<GroceryList />} />
<Route path="/manage" element={<Manage />} />
<Route path="/settings" element={<Settings />} />
{/* Private routes with layout */} <Route
<Route path="/admin"
element={ element={
<PrivateRoute> <RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>
<AppLayout /> <AdminPanel />
</PrivateRoute> </RoleGuard>
} }
> />
<Route path="/" element={<GroceryList />} /> </Route>
<Route path="/manage" element={<Manage />} /> </Routes>
<Route path="/settings" element={<Settings />} /> <UploadToaster />
</BrowserRouter>
<Route </SettingsProvider>
path="/admin" </ActionToastProvider>
element={ </UploadQueueProvider>
<RoleGuard allowed={[ROLES.SYSTEM_ADMIN]}>
<AdminPanel />
</RoleGuard>
}
/>
</Route>
</Routes>
</BrowserRouter>
</SettingsProvider>
</StoreProvider> </StoreProvider>
</HouseholdProvider> </HouseholdProvider>
</AuthProvider> </AuthProvider>

View File

@ -56,3 +56,38 @@ export const updateMemberRole = (householdId, userId, role) =>
*/ */
export const removeMember = (householdId, userId) => export const removeMember = (householdId, userId) =>
api.delete(`/households/${householdId}/members/${userId}`); api.delete(`/households/${householdId}/members/${userId}`);
function groupHeaders(groupId) {
return {
headers: {
"x-group-id": String(groupId),
},
};
}
export const getGroupInviteLinks = (groupId) =>
api.get("/api/groups/invites", groupHeaders(groupId));
export const createGroupInviteLink = (groupId, payload) =>
api.post("/api/groups/invites", payload, groupHeaders(groupId));
export const revokeGroupInviteLink = (groupId, linkId) =>
api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId));
export const reviveGroupInviteLink = (groupId, linkId, ttlDays) =>
api.post("/api/groups/invites/revive", { linkId, ttlDays }, groupHeaders(groupId));
export const deleteGroupInviteLink = (groupId, linkId) =>
api.post("/api/groups/invites/delete", { linkId }, groupHeaders(groupId));
export const getGroupJoinPolicy = (groupId) =>
api.get("/api/groups/join-policy", groupHeaders(groupId));
export const setGroupJoinPolicy = (groupId, joinPolicy) =>
api.post("/api/groups/join-policy", { joinPolicy }, groupHeaders(groupId));
export const getInviteLinkSummary = (token) =>
api.get(`/api/invite-links/${encodeURIComponent(token)}`);
export const acceptInviteLink = (token) =>
api.post(`/api/invite-links/${encodeURIComponent(token)}`);

View File

@ -1,8 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores"; import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/admin/StoreManagement.css"; import "../../styles/components/admin/StoreManagement.css";
export default function StoreManagement() { export default function StoreManagement() {
const toast = useActionToast();
const [stores, setStores] = useState([]); const [stores, setStores] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingStore, setEditingStore] = useState(null); const [editingStore, setEditingStore] = useState(null);
@ -24,7 +27,8 @@ export default function StoreManagement() {
setStores(response.data); setStores(response.data);
} catch (error) { } catch (error) {
console.error("Failed to load stores:", error); console.error("Failed to load stores:", error);
alert("Failed to load stores"); const message = getApiErrorMessage(error, "Failed to load stores");
toast.error("Load stores failed", `Load stores failed: ${message}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -38,12 +42,14 @@ export default function StoreManagement() {
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null; const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
await createStore(formData.name, zonesJson); await createStore(formData.name, zonesJson);
await loadStores(); await loadStores();
toast.success("Created store", `Created store ${formData.name.trim()}`);
setShowCreateForm(false); setShowCreateForm(false);
setFormData({ name: "", zones: [] }); setFormData({ name: "", zones: [] });
setNewZone(""); setNewZone("");
} catch (error) { } catch (error) {
console.error("Failed to create store:", error); console.error("Failed to create store:", error);
alert(error.response?.data?.error || "Failed to create store"); const message = getApiErrorMessage(error, "Failed to create store");
toast.error("Create store failed", `Create store failed: ${message}`);
} }
}; };
@ -55,12 +61,14 @@ export default function StoreManagement() {
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null; const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
await updateStore(editingStore.id, formData.name, zonesJson); await updateStore(editingStore.id, formData.name, zonesJson);
await loadStores(); await loadStores();
toast.success("Updated store", `Updated store ${formData.name.trim()}`);
setEditingStore(null); setEditingStore(null);
setFormData({ name: "", zones: [] }); setFormData({ name: "", zones: [] });
setNewZone(""); setNewZone("");
} catch (error) { } catch (error) {
console.error("Failed to update store:", error); console.error("Failed to update store:", error);
alert(error.response?.data?.error || "Failed to update store"); const message = getApiErrorMessage(error, "Failed to update store");
toast.error("Update store failed", `Update store failed: ${message}`);
} }
}; };
@ -70,9 +78,11 @@ export default function StoreManagement() {
try { try {
await deleteStore(storeId); await deleteStore(storeId);
await loadStores(); await loadStores();
toast.success("Deleted store", `Deleted store ${storeName}`);
} catch (error) { } catch (error) {
console.error("Failed to delete store:", error); console.error("Failed to delete store:", error);
alert(error.response?.data?.error || "Failed to delete store"); const message = getApiErrorMessage(error, "Failed to delete store");
toast.error("Delete store failed", `Delete store failed: ${message}`);
} }
}; };

View File

@ -0,0 +1,92 @@
import useUploadQueue from "../../hooks/useUploadQueue";
import useActionToast from "../../hooks/useActionToast";
import "../../styles/components/UploadToaster.css";
function getStatusLabel(upload, isOnline) {
if (upload.status === "uploading") {
return `Uploading... ${upload.progress || 0}%`;
}
if (upload.status === "success") {
return "Upload complete";
}
if (upload.status === "queued") {
return isOnline ? "Queued for upload..." : "Waiting for network...";
}
return upload.lastError || "Upload failed. Retry or discard.";
}
export default function UploadToaster() {
const { uploads, isOnline, retryUpload, discardUpload } = useUploadQueue();
const { toasts, dismiss } = useActionToast();
if (!uploads.length && !toasts.length) {
return null;
}
const sortedToasts = [...toasts].sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
const sortedUploads = [...uploads].sort(
(a, b) => (b.updatedAt || b.createdAt || 0) - (a.updatedAt || a.createdAt || 0)
);
return (
<div className="upload-toaster" aria-live="polite" aria-atomic="false">
{sortedToasts.map((toast) => (
<div
key={toast.id}
className={`upload-toast action-toast action-toast-${toast.variant}`}
role={toast.variant === "error" ? "alert" : "status"}
>
<div className="action-toast-header">
<div className="upload-toast-title">{toast.title}</div>
<button
type="button"
onClick={() => dismiss(toast.id)}
className="action-toast-close"
aria-label="Dismiss notification"
>
x
</button>
</div>
{toast.message ? <div className="upload-toast-status">{toast.message}</div> : null}
</div>
))}
{sortedUploads.map((upload) => (
<div
key={upload.id}
className={`upload-toast upload-toast-${upload.status}`}
role="status"
>
<div className="upload-toast-title">{upload.itemName}</div>
<div className="upload-toast-status">{getStatusLabel(upload, isOnline)}</div>
<div className="upload-toast-progress">
<div
className="upload-toast-progress-fill"
style={{ width: `${upload.status === "success" ? 100 : upload.progress || 0}%` }}
/>
</div>
{upload.status === "failed" && (
<div className="upload-toast-actions">
<button type="button" onClick={() => retryUpload(upload.id)}>
Retry
</button>
<button type="button" onClick={() => discardUpload(upload.id)}>
Discard
</button>
</div>
)}
{upload.status === "queued" && (
<div className="upload-toast-actions">
<button type="button" onClick={() => discardUpload(upload.id)}>
Discard
</button>
</div>
)}
</div>
))}
</div>
);
}

View File

@ -37,7 +37,14 @@ export default function AddItemForm({
e.preventDefault(); e.preventDefault();
if (!itemName.trim()) return; if (!itemName.trim()) return;
const targetUserId = assignmentMode === "others" ? assignedUserId : null; if (assignmentMode === "others" && assignedUserId == null) {
if (otherMembers.length > 0) {
setShowAssignModal(true);
}
return;
}
const targetUserId = assignmentMode === "others" ? Number(assignedUserId) : null;
onAdd(itemName, quantity, targetUserId); onAdd(itemName, quantity, targetUserId);
setItemName(""); setItemName("");
setQuantity(1); setQuantity(1);
@ -124,7 +131,7 @@ export default function AddItemForm({
value={assignmentMode} value={assignmentMode}
ariaLabel="Item assignment mode" ariaLabel="Item assignment mode"
className="tbg-group add-item-form-assignee-toggle" className="tbg-group add-item-form-assignee-toggle"
sizeClassName="tbg-size-xs" sizeClassName="tbg-size-xxs"
options={[ options={[
{ value: "me", label: "Me" }, { value: "me", label: "Me" },
{ value: "others", label: "Others", disabled: otherMembers.length === 0 } { value: "others", label: "Others", disabled: otherMembers.length === 0 }

View File

@ -1,13 +1,17 @@
import "../../styles/components/Navbar.css"; import "../../styles/components/Navbar.css";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { logoutRequest } from "../../api/auth"; import { logoutRequest } from "../../api/auth";
import { AuthContext } from "../../context/AuthContext"; import { AuthContext } from "../../context/AuthContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import HouseholdSwitcher from "../household/HouseholdSwitcher"; import HouseholdSwitcher from "../household/HouseholdSwitcher";
export default function Navbar() { export default function Navbar() {
const navigate = useNavigate();
const { role, logout, username } = useContext(AuthContext); const { role, logout, username } = useContext(AuthContext);
const toast = useActionToast();
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const closeMenus = () => { const closeMenus = () => {
@ -15,14 +19,23 @@ export default function Navbar() {
}; };
const handleLogout = async () => { const handleLogout = async () => {
let loggedOutRemotely = true;
let fallbackReason = "";
try { try {
await logoutRequest(); await logoutRequest();
} catch (_) { } catch (error) {
// Clear local auth state even if server logout fails. // Clear local auth state even if server logout fails.
loggedOutRemotely = false;
fallbackReason = getApiErrorMessage(error, "Unable to end server session");
} finally { } finally {
logout(); logout();
closeMenus(); closeMenus();
window.location.href = "/login"; if (loggedOutRemotely) {
toast.success("Logged out", "Logged out successfully");
} else {
toast.info("Logged out locally", `Server logout failed: ${fallbackReason}`);
}
navigate("/login");
} }
}; };

View File

@ -1,16 +1,39 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { createHousehold, joinHousehold } from "../../api/households"; import { createHousehold, joinHousehold } from "../../api/households";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/CreateJoinHousehold.css"; import "../../styles/components/manage/CreateJoinHousehold.css";
export default function CreateJoinHousehold({ onClose }) { export default function CreateJoinHousehold({ onClose }) {
const navigate = useNavigate();
const toast = useActionToast();
const { refreshHouseholds } = useContext(HouseholdContext); const { refreshHouseholds } = useContext(HouseholdContext);
const [mode, setMode] = useState("create"); // "create" or "join" const [mode, setMode] = useState("create");
const [householdName, setHouseholdName] = useState(""); const [householdName, setHouseholdName] = useState("");
const [inviteCode, setInviteCode] = useState(""); const [inviteCode, setInviteCode] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const extractInviteToken = (value) => {
const trimmed = value.trim();
if (!trimmed) return null;
const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/);
if (directMatch) return directMatch[1];
try {
const parsed = new URL(trimmed, window.location.origin);
const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/);
if (urlMatch) return urlMatch[1];
} catch (error) {
return null;
}
return null;
};
const handleCreate = async (e) => { const handleCreate = async (e) => {
e.preventDefault(); e.preventDefault();
if (!householdName.trim()) return; if (!householdName.trim()) return;
@ -21,10 +44,13 @@ export default function CreateJoinHousehold({ onClose }) {
try { try {
await createHousehold(householdName); await createHousehold(householdName);
await refreshHouseholds(); await refreshHouseholds();
toast.success("Created household", `Created household ${householdName.trim()}`);
onClose(); onClose();
} catch (err) { } catch (err) {
console.error("Failed to create household:", err); console.error("Failed to create household:", err);
setError(err.response?.data?.message || "Failed to create household"); const message = getApiErrorMessage(err, "Failed to create household");
setError(message);
toast.error("Create household failed", `Create household failed: ${message}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -38,12 +64,23 @@ export default function CreateJoinHousehold({ onClose }) {
setError(""); setError("");
try { try {
const inviteToken = extractInviteToken(inviteCode);
if (inviteToken) {
toast.info("Invite link detected", "Opening invite details");
onClose();
navigate(`/invite/${inviteToken}`);
return;
}
await joinHousehold(inviteCode); await joinHousehold(inviteCode);
await refreshHouseholds(); await refreshHouseholds();
toast.success("Joined household", "Joined household successfully");
onClose(); onClose();
} catch (err) { } catch (err) {
console.error("Failed to join household:", err); console.error("Failed to join household:", err);
setError(err.response?.data?.message || "Failed to join household"); const message = getApiErrorMessage(err, "Failed to join household");
setError(message);
toast.error("Join household failed", `Join household failed: ${message}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -54,7 +91,7 @@ export default function CreateJoinHousehold({ onClose }) {
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}> <div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2>Household</h2> <h2>Household</h2>
<button className="close-btn" onClick={onClose}>×</button> <button className="close-btn" onClick={onClose}>&times;</button>
</div> </div>
<div className="mode-tabs"> <div className="mode-tabs">
@ -100,18 +137,18 @@ export default function CreateJoinHousehold({ onClose }) {
) : ( ) : (
<form onSubmit={handleJoin} className="household-form"> <form onSubmit={handleJoin} className="household-form">
<div className="form-group"> <div className="form-group">
<label htmlFor="inviteCode">Invite Code</label> <label htmlFor="inviteCode">Invite Code or Link</label>
<input <input
id="inviteCode" id="inviteCode"
type="text" type="text"
value={inviteCode} value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)} onChange={(e) => setInviteCode(e.target.value)}
placeholder="Enter invite code" placeholder="Invite code or /invite URL"
required required
autoFocus autoFocus
/> />
<p className="form-hint"> <p className="form-hint">
Ask the household admin for the invite code Paste a raw invite code or full invite link URL
</p> </p>
</div> </div>
<div className="form-actions"> <div className="form-actions">

View File

@ -1,30 +1,59 @@
import React, { useContext, useEffect, useState } from "react"; import React, { useContext, useEffect, useState } from "react";
import { import {
createGroupInviteLink,
deleteGroupInviteLink,
deleteHousehold, deleteHousehold,
getGroupInviteLinks,
getGroupJoinPolicy,
getHouseholdMembers, getHouseholdMembers,
refreshInviteCode, refreshInviteCode,
removeMember, removeMember,
revokeGroupInviteLink,
reviveGroupInviteLink,
setGroupJoinPolicy,
updateHousehold, updateHousehold,
updateMemberRole updateMemberRole
} from "../../api/households"; } from "../../api/households";
import { ToggleButtonGroup } from "../common";
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
import { AuthContext } from "../../context/AuthContext"; import { AuthContext } from "../../context/AuthContext";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/ManageHousehold.css"; import "../../styles/components/manage/ManageHousehold.css";
const JOIN_POLICY_OPTIONS = [
{ label: "Disabled", value: "NOT_ACCEPTING" },
{ label: "Auto", value: "AUTO_ACCEPT" },
{ label: "Manual", value: "APPROVAL_REQUIRED" },
];
export default function ManageHousehold() { export default function ManageHousehold() {
const { userId } = useContext(AuthContext); const { userId } = useContext(AuthContext);
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext); const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
const toast = useActionToast();
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [showInviteCode, setShowInviteCode] = useState(false); const [showInviteCode, setShowInviteCode] = useState(false);
const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING");
const [inviteLinks, setInviteLinks] = useState([]);
const [inviteLoading, setInviteLoading] = useState(false);
const [inviteError, setInviteError] = useState("");
const [ttlDays, setTtlDays] = useState(7);
const [singleUseMode, setSingleUseMode] = useState("UNLIMITED");
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const isAdmin = activeHousehold?.role === "admin"; const isManager = ["owner", "admin"].includes(activeHousehold?.role);
const isMemberOnly = activeHousehold?.role === "member";
useEffect(() => { useEffect(() => {
loadMembers(); loadMembers();
}, [activeHousehold?.id]); if (isManager) {
loadJoinAndInvites();
}
}, [activeHousehold?.id, isManager]);
const loadMembers = async () => { const loadMembers = async () => {
if (!activeHousehold?.id) return; if (!activeHousehold?.id) return;
@ -39,6 +68,147 @@ export default function ManageHousehold() {
} }
}; };
const loadJoinAndInvites = async () => {
if (!activeHousehold?.id || !isManager) return;
setInviteLoading(true);
setInviteError("");
try {
const [policyResponse, linksResponse] = await Promise.all([
getGroupJoinPolicy(activeHousehold.id),
getGroupInviteLinks(activeHousehold.id),
]);
setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING");
setInviteLinks(linksResponse.data.links || []);
} catch (error) {
setInviteError(error.response?.data?.error?.message || "Failed to load invite links");
} finally {
setInviteLoading(false);
}
};
const getLinkStatus = (link) => {
const now = Date.now();
if (link.single_use && link.used_at) return "Used";
if (link.revoked_at) return "Revoked";
if (new Date(link.expires_at).getTime() <= now) return "Expired";
return "Active";
};
const copyTextToClipboard = async (text) => {
if (!text) return false;
if (navigator?.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Fall through to legacy copy fallback.
}
}
try {
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "");
textArea.style.position = "fixed";
textArea.style.top = "-9999px";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const copied = document.execCommand("copy");
document.body.removeChild(textArea);
return copied;
} catch {
return false;
}
};
const copyInviteLink = async (token) => {
const inviteUrl = `${window.location.origin}/invite/${encodeURIComponent(token)}`;
const copied = await copyTextToClipboard(inviteUrl);
if (copied) {
const tokenLast4 = String(token || "").slice(-4);
toast.info("Copied invite link", `Copied invite link ending in ${tokenLast4}`);
return;
}
toast.error(
"Copy invite link failed",
"Copy invite link failed: unable to access clipboard. Copy manually."
);
};
const handleCreateInviteLink = async () => {
try {
setInviteError("");
await createGroupInviteLink(activeHousehold.id, {
policy: joinPolicy,
singleUse: singleUseMode === "ONE_TIME",
ttlDays,
});
await loadJoinAndInvites();
toast.success("Created invite link", `Created invite link (${ttlDays} day TTL)`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to create invite link");
setInviteError(message);
toast.error("Create invite link failed", `Create invite link failed: ${message}`);
}
};
const handleUpdateJoinPolicy = async (value) => {
try {
setInviteError("");
await setGroupJoinPolicy(activeHousehold.id, value);
setJoinPolicyValue(value);
toast.success("Updated join policy", `Join policy set to ${value}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update join policy");
setInviteError(message);
toast.error("Update join policy failed", `Update join policy failed: ${message}`);
}
};
const handleRevokeInvite = async (linkId) => {
try {
setInviteError("");
await revokeGroupInviteLink(activeHousehold.id, linkId);
await loadJoinAndInvites();
toast.success("Revoked invite link", "Revoked invite link");
} catch (error) {
const message = getApiErrorMessage(error, "Failed to revoke invite link");
setInviteError(message);
toast.error("Revoke invite link failed", `Revoke invite link failed: ${message}`);
}
};
const handleReviveInvite = async (linkId) => {
try {
setInviteError("");
await reviveGroupInviteLink(activeHousehold.id, linkId, ttlDays);
await loadJoinAndInvites();
toast.success("Revived invite link", `Revived invite link for ${ttlDays} days`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to revive invite link");
setInviteError(message);
toast.error("Revive invite link failed", `Revive invite link failed: ${message}`);
}
};
const handleDeleteInvite = async (linkId) => {
if (!confirm("Delete this invite link permanently?")) return;
try {
setInviteError("");
await deleteGroupInviteLink(activeHousehold.id, linkId);
await loadJoinAndInvites();
toast.success("Deleted invite link", "Deleted invite link");
} catch (error) {
const message = getApiErrorMessage(error, "Failed to delete invite link");
setInviteError(message);
toast.error("Delete invite link failed", `Delete invite link failed: ${message}`);
}
};
const handleUpdateName = async () => { const handleUpdateName = async () => {
if (!newName.trim() || newName === activeHousehold.name) { if (!newName.trim() || newName === activeHousehold.name) {
setEditingName(false); setEditingName(false);
@ -46,15 +216,13 @@ export default function ManageHousehold() {
} }
try { try {
console.log('Updating household:', activeHousehold.id, 'with name:', newName); await updateHousehold(activeHousehold.id, newName);
const response = await updateHousehold(activeHousehold.id, newName);
console.log('Update response:', response);
await refreshHouseholds(); await refreshHouseholds();
toast.success("Updated household name", `Updated household name to ${newName.trim()}`);
setEditingName(false); setEditingName(false);
} catch (error) { } catch (error) {
console.error("Failed to update household name:", error); const message = getApiErrorMessage(error, "Failed to update household name");
console.error("Error response:", error.response?.data); toast.error("Update household name failed", `Update household name failed: ${message}`);
alert(`Failed to update household name: ${error.response?.data?.error || error.message}`);
} }
}; };
@ -62,46 +230,39 @@ export default function ManageHousehold() {
if (!confirm("Generate a new invite code? The old code will no longer work.")) return; if (!confirm("Generate a new invite code? The old code will no longer work.")) return;
try { try {
const response = await refreshInviteCode(activeHousehold.id); await refreshInviteCode(activeHousehold.id);
await refreshHouseholds(); await refreshHouseholds();
const refreshedInviteCode = response.data?.household?.invite_code; toast.success("Generated new invite code", "Generated a new invite code");
if (refreshedInviteCode) {
alert(`New invite code: ${refreshedInviteCode}`);
} else {
alert("Invite code refreshed successfully");
}
} catch (error) { } catch (error) {
console.error( const message = getApiErrorMessage(error, "Failed to refresh invite code");
"Failed to refresh invite code:", toast.error("Refresh invite code failed", `Refresh invite code failed: ${message}`);
error?.response?.data?.error?.message ||
error?.response?.data?.message ||
error?.message
);
alert("Failed to refresh invite code");
} }
}; };
const handleUpdateRole = async (userId, currentRole) => { const handleUpdateRole = async (memberId, currentRole, memberName) => {
if (currentRole === "owner") return;
const newRole = currentRole === "admin" ? "member" : "admin"; const newRole = currentRole === "admin" ? "member" : "admin";
try { try {
await updateMemberRole(activeHousehold.id, userId, newRole); await updateMemberRole(activeHousehold.id, memberId, newRole);
await loadMembers(); await loadMembers();
toast.success("Updated member role", `Updated role for ${memberName} to ${newRole}`);
} catch (error) { } catch (error) {
console.error("Failed to update role:", error); const message = getApiErrorMessage(error, "Failed to update member role");
alert("Failed to update member role"); toast.error("Update member role failed", `Update member role failed: ${message}`);
} }
}; };
const handleRemoveMember = async (userId, username) => { const handleRemoveMember = async (memberId, username) => {
if (!confirm(`Remove ${username} from this household?`)) return; if (!confirm(`Remove ${username} from this household?`)) return;
try { try {
await removeMember(activeHousehold.id, userId); await removeMember(activeHousehold.id, memberId);
await loadMembers(); await loadMembers();
toast.success("Removed member", `Removed member ${username}`);
} catch (error) { } catch (error) {
console.error("Failed to remove member:", error); const message = getApiErrorMessage(error, "Failed to remove member");
alert("Failed to remove member"); toast.error("Remove member failed", `Remove member failed: ${message}`);
} }
}; };
@ -110,22 +271,46 @@ export default function ManageHousehold() {
if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return; if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return;
try { try {
const householdName = activeHousehold.name;
await deleteHousehold(activeHousehold.id); await deleteHousehold(activeHousehold.id);
await refreshHouseholds(); await refreshHouseholds();
toast.success("Deleted household", `Deleted household ${householdName}`);
} catch (error) { } catch (error) {
console.error("Failed to delete household:", error); const message = getApiErrorMessage(error, "Failed to delete household");
alert("Failed to delete household"); toast.error("Delete household failed", `Delete household failed: ${message}`);
} }
}; };
const copyInviteCode = () => { const handleLeaveHousehold = async () => {
navigator.clipboard.writeText(activeHousehold.invite_code); if (!activeHousehold?.id) return;
alert("Invite code copied to clipboard!");
try {
const householdName = activeHousehold.name;
await removeMember(activeHousehold.id, parseInt(userId, 10));
setIsLeaveModalOpen(false);
await refreshHouseholds();
toast.success("Left household", `Left household ${householdName}`);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to leave household");
toast.error("Leave household failed", `Leave household failed: ${message}`);
}
};
const copyInviteCode = async () => {
const copied = await copyTextToClipboard(activeHousehold.invite_code);
if (copied) {
toast.info("Copied invite code", "Copied invite code to clipboard");
return;
}
toast.error(
"Copy invite code failed",
"Copy invite code failed: unable to access clipboard. Copy manually."
);
}; };
return ( return (
<div className="manage-household"> <div className="manage-household">
{/* Household Name Section */}
<section key="household-name" className="manage-section"> <section key="household-name" className="manage-section">
<h2>Household Name</h2> <h2>Household Name</h2>
{editingName ? ( {editingName ? (
@ -143,7 +328,7 @@ export default function ManageHousehold() {
) : ( ) : (
<div className="name-display"> <div className="name-display">
<h3>{activeHousehold.name}</h3> <h3>{activeHousehold.name}</h3>
{isAdmin && ( {isManager && (
<button <button
onClick={() => { onClick={() => {
setNewName(activeHousehold.name); setNewName(activeHousehold.name);
@ -158,12 +343,11 @@ export default function ManageHousehold() {
)} )}
</section> </section>
{/* Invite Code Section */} {isManager && (
{isAdmin && (
<section key="invite-code" className="manage-section"> <section key="invite-code" className="manage-section">
<h2>Invite Code</h2> <h2>Legacy Invite Code</h2>
<p className="section-description"> <p className="section-description">
Share this code with others to invite them to your household. Share this code for legacy join-by-code flows.
</p> </p>
<div className="invite-actions"> <div className="invite-actions">
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary"> <button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
@ -182,7 +366,85 @@ export default function ManageHousehold() {
</section> </section>
)} )}
{/* Members Section */} {isManager && (
<section key="join-and-invites" className="manage-section">
<h2>Join and Invites</h2>
{inviteError && <p className="section-error">{inviteError}</p>}
<ToggleButtonGroup
value={joinPolicy}
ariaLabel="Join policy options"
className="tbg-group manage-household-join-policy-toggle"
options={JOIN_POLICY_OPTIONS.map((option) => ({
...option,
disabled: inviteLoading
}))}
onChange={handleUpdateJoinPolicy}
/>
<div className="invite-controls">
<label>
TTL
<select value={ttlDays} onChange={(e) => setTtlDays(Number(e.target.value))}>
{[1, 2, 3, 4, 5, 6, 7].map((day) => (
<option key={day} value={day}>{day} day{day > 1 ? "s" : ""}</option>
))}
</select>
</label>
<label>
Usage
<select value={singleUseMode} onChange={(e) => setSingleUseMode(e.target.value)}>
<option value="UNLIMITED">Unlimited</option>
<option value="ONE_TIME">1 use</option>
</select>
</label>
<button className="btn-primary" onClick={handleCreateInviteLink} disabled={inviteLoading}>
Create link
</button>
</div>
{inviteLoading ? (
<p>Loading invite links...</p>
) : inviteLinks.length === 0 ? (
<p className="section-description">No invite links yet.</p>
) : (
<div className="invite-links-list">
{inviteLinks.map((link) => {
const status = getLinkStatus(link);
const isActive = status === "Active";
return (
<div key={link.id} className="invite-link-card">
<div>
<p className="invite-link-token">Token ending in {String(link.token).slice(-4)}</p>
<p className="invite-link-meta">
Status: <strong>{status}</strong> | Policy: {link.policy} | TTL: until {new Date(link.expires_at).toLocaleString()}
</p>
</div>
<div className="invite-link-actions">
<button className="btn-secondary btn-small" onClick={() => copyInviteLink(link.token)}>
Copy link
</button>
{isActive ? (
<button className="btn-secondary btn-small" onClick={() => handleRevokeInvite(link.id)}>
Revoke
</button>
) : (
<button className="btn-secondary btn-small" onClick={() => handleReviveInvite(link.id)}>
Revive
</button>
)}
<button className="btn-danger btn-small" onClick={() => handleDeleteInvite(link.id)}>
Delete
</button>
</div>
</div>
);
})}
</div>
)}
</section>
)}
<section key="members" className="manage-section"> <section key="members" className="manage-section">
<h2>Members ({members.length})</h2> <h2>Members ({members.length})</h2>
{loading ? ( {loading ? (
@ -192,22 +454,15 @@ export default function ManageHousehold() {
{members.map((member) => ( {members.map((member) => (
<div key={member.id} className="member-card"> <div key={member.id} className="member-card">
<div className="member-info"> <div className="member-info">
<span className="member-role"> <span className="member-role">{member.role}</span>
{member.role}
</span>
<span className="member-name"> <span className="member-name">
{` {member.username} [{member.id}] {member.id === parseInt(userId, 10) ? "(You)" : ""}
${member.username}
[${member.id}]
${(member.id === parseInt(userId) ? " (You)" : "")}
`}
</span> </span>
</div> </div>
{isAdmin && member.id !== parseInt(userId) && ( {isManager && member.id !== parseInt(userId, 10) && member.role !== "owner" && (
<div className="member-actions"> <div className="member-actions">
<button <button
onClick={() => handleUpdateRole(member.id, member.role)} onClick={() => handleUpdateRole(member.id, member.role, member.username)}
className="btn-secondary btn-small" className="btn-secondary btn-small"
> >
{member.role === "admin" ? "Make Member" : "Make Admin"} {member.role === "admin" ? "Make Member" : "Make Admin"}
@ -226,18 +481,34 @@ export default function ManageHousehold() {
)} )}
</section> </section>
{/* Danger Zone */} {(isManager || isMemberOnly) && (
{isAdmin && (
<section key="danger-zone" className="manage-section danger-zone"> <section key="danger-zone" className="manage-section danger-zone">
<h2>Danger Zone</h2> <h2>Danger Zone</h2>
<p className="section-description"> <p className="section-description">
Deleting a household is permanent and will delete all lists, items, and history. {isMemberOnly
? "Leaving removes your access to this household."
: "Deleting a household is permanent and will delete all lists, items, and history."}
</p> </p>
<button onClick={handleDeleteHousehold} className="btn-danger"> {isMemberOnly ? (
Delete Household <button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
</button> Leave Household
</button>
) : (
<button onClick={handleDeleteHousehold} className="btn-danger">
Delete Household
</button>
)}
</section> </section>
)} )}
<ConfirmSlideModal
isOpen={isLeaveModalOpen}
title={`Leave "${activeHousehold?.name || "this household"}"?`}
description="Slide all the way to confirm. You will lose access to this household."
confirmLabel="Leave Household"
onClose={() => setIsLeaveModalOpen(false)}
onConfirm={handleLeaveHousehold}
/>
</div> </div>
); );
} }

View File

@ -7,16 +7,19 @@ import {
} from "../../api/stores"; } from "../../api/stores";
import { HouseholdContext } from "../../context/HouseholdContext"; import { HouseholdContext } from "../../context/HouseholdContext";
import { StoreContext } from "../../context/StoreContext"; import { StoreContext } from "../../context/StoreContext";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/manage/ManageStores.css"; import "../../styles/components/manage/ManageStores.css";
export default function ManageStores() { export default function ManageStores() {
const { activeHousehold } = useContext(HouseholdContext); const { activeHousehold } = useContext(HouseholdContext);
const { stores: householdStores, refreshStores } = useContext(StoreContext); const { stores: householdStores, refreshStores } = useContext(StoreContext);
const toast = useActionToast();
const [allStores, setAllStores] = useState([]); const [allStores, setAllStores] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAddStore, setShowAddStore] = useState(false); const [showAddStore, setShowAddStore] = useState(false);
const isAdmin = activeHousehold?.role === "admin"; const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
useEffect(() => { useEffect(() => {
loadAllStores(); loadAllStores();
@ -35,14 +38,17 @@ export default function ManageStores() {
}; };
const handleAddStore = async (storeId) => { const handleAddStore = async (storeId) => {
const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
try { try {
console.log("Adding store with ID:", storeId); console.log("Adding store with ID:", storeId);
await addStoreToHousehold(activeHousehold.id, storeId, false); await addStoreToHousehold(activeHousehold.id, storeId, false);
await refreshStores(); await refreshStores();
toast.success("Added store", `Added store ${storeName}`);
setShowAddStore(false); setShowAddStore(false);
} catch (error) { } catch (error) {
console.error("Failed to add store:", error); console.error("Failed to add store:", error);
alert("Failed to add store"); const message = getApiErrorMessage(error, "Failed to add store");
toast.error("Add store failed", `Add store failed: ${message}`);
} }
}; };
@ -52,19 +58,25 @@ export default function ManageStores() {
try { try {
await removeStoreFromHousehold(activeHousehold.id, storeId); await removeStoreFromHousehold(activeHousehold.id, storeId);
await refreshStores(); await refreshStores();
toast.success("Removed store", `Removed store ${storeName}`);
} catch (error) { } catch (error) {
console.error("Failed to remove store:", error); console.error("Failed to remove store:", error);
alert("Failed to remove store"); const message = getApiErrorMessage(error, "Failed to remove store");
toast.error("Remove store failed", `Remove store failed: ${message}`);
} }
}; };
const handleSetDefault = async (storeId) => { const handleSetDefault = async (storeId) => {
const storeName =
householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
try { try {
await setDefaultStore(activeHousehold.id, storeId); await setDefaultStore(activeHousehold.id, storeId);
await refreshStores(); await refreshStores();
toast.success("Updated default store", `Default store set to ${storeName}`);
} catch (error) { } catch (error) {
console.error("Failed to set default store:", error); console.error("Failed to set default store:", error);
alert("Failed to set default store"); const message = getApiErrorMessage(error, "Failed to set default store");
toast.error("Set default store failed", `Set default store failed: ${message}`);
} }
}; };

View File

@ -0,0 +1,191 @@
import { useEffect, useRef, useState } from "react";
import "../../styles/components/ConfirmSlideModal.css";
const HANDLE_SIZE = 40;
export default function ConfirmSlideModal({
isOpen,
title,
description,
confirmLabel = "Confirm",
onClose,
onConfirm
}) {
const trackRef = useRef(null);
const endFlashTimeoutRef = useRef(null);
const reachedEndRef = useRef(false);
const [dragX, setDragX] = useState(0);
const [dragging, setDragging] = useState(false);
const [isAtEnd, setIsAtEnd] = useState(false);
const [endFlash, setEndFlash] = useState(false);
const getDragPositionFromClientX = (clientX) => {
const track = trackRef.current;
if (!track) return 0;
const rect = track.getBoundingClientRect();
return Math.min(
Math.max(0, clientX - rect.left - HANDLE_SIZE / 2),
rect.width - HANDLE_SIZE
);
};
const isEndPosition = (position) => {
const track = trackRef.current;
if (!track) return false;
const maxDrag = track.clientWidth - HANDLE_SIZE;
const endTolerancePx = 1;
return position >= maxDrag - endTolerancePx;
};
const triggerEndFeedback = () => {
setEndFlash(true);
if (endFlashTimeoutRef.current) {
clearTimeout(endFlashTimeoutRef.current);
}
endFlashTimeoutRef.current = setTimeout(() => setEndFlash(false), 140);
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate(16);
}
};
const handlePointerDown = (event) => {
event.preventDefault();
setDragging(true);
reachedEndRef.current = false;
setIsAtEnd(false);
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event) => {
if (!dragging) return;
const next = getDragPositionFromClientX(event.clientX);
const nextAtEnd = isEndPosition(next);
setDragX(next);
setIsAtEnd((prev) => (prev === nextAtEnd ? prev : nextAtEnd));
if (nextAtEnd && !reachedEndRef.current) {
reachedEndRef.current = true;
triggerEndFeedback();
}
if (!nextAtEnd) {
reachedEndRef.current = false;
}
};
const handlePointerUp = (event) => {
if (!dragging) return;
setDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
const releaseX = getDragPositionFromClientX(event.clientX);
const releaseAtEnd = isEndPosition(releaseX);
setIsAtEnd((prev) => (prev ? false : prev));
if (releaseAtEnd && !reachedEndRef.current) {
triggerEndFeedback();
}
setDragX(0);
if (releaseAtEnd) {
onConfirm();
}
};
const handlePointerCancel = (event) => {
if (!dragging) return;
setDragging(false);
event.currentTarget.releasePointerCapture(event.pointerId);
setIsAtEnd((prev) => (prev ? false : prev));
setDragX(0);
};
useEffect(() => {
return () => {
if (endFlashTimeoutRef.current) {
clearTimeout(endFlashTimeoutRef.current);
}
};
}, []);
useEffect(() => {
if (!isOpen) return undefined;
const handleKeyDown = (event) => {
if (event.key === "Escape") {
onClose();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (!isOpen) {
setDragX(0);
setDragging(false);
setIsAtEnd(false);
setEndFlash(false);
reachedEndRef.current = false;
}
}, [isOpen]);
if (!isOpen) return null;
const isActive = isAtEnd || endFlash;
return (
<div className="confirm-slide-overlay" onClick={onClose}>
<div className="confirm-slide-modal" onClick={(event) => event.stopPropagation()}>
<h2 className="confirm-slide-title">{title}</h2>
{description ? <p className="confirm-slide-description">{description}</p> : null}
<div className="confirm-slide-track-wrap">
<div className="confirm-slide-helper">Slide to confirm</div>
<div
ref={trackRef}
className={`confirm-slide-track ${isActive ? "is-active" : ""}`}
>
<div
className="confirm-slide-progress"
style={{ width: dragX + HANDLE_SIZE }}
/>
<div className={`confirm-slide-ready ${isActive ? "is-visible" : ""}`}>
release
</div>
<button
type="button"
className={`confirm-slide-handle ${isActive ? "is-active" : ""}`}
style={{ transform: `translateX(${dragX}px)` }}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
aria-label="Slide to confirm"
>
>
</button>
</div>
</div>
<div className="confirm-slide-footer">
<span className="confirm-slide-label">{confirmLabel}</span>
<button
type="button"
className="confirm-slide-cancel btn btn-outline"
onClick={onClose}
>
Cancel
</button>
</div>
</div>
</div>
);
}

View File

@ -1,9 +1,12 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications"; import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
import useActionToast from "../../hooks/useActionToast";
import getApiErrorMessage from "../../lib/getApiErrorMessage";
import "../../styles/components/EditItemModal.css"; import "../../styles/components/EditItemModal.css";
import AddImageModal from "./AddImageModal"; import AddImageModal from "./AddImageModal";
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) { export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) {
const toast = useActionToast();
const [itemName, setItemName] = useState(item.item_name || ""); const [itemName, setItemName] = useState(item.item_name || "");
const [quantity, setQuantity] = useState(item.quantity || 1); const [quantity, setQuantity] = useState(item.quantity || 1);
const [itemType, setItemType] = useState(""); const [itemType, setItemType] = useState("");
@ -54,7 +57,8 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
await onSave(item.id, itemName, quantity, classification); await onSave(item.id, itemName, quantity, classification);
} catch (error) { } catch (error) {
console.error("Failed to save:", error); console.error("Failed to save:", error);
alert("Failed to save changes"); const message = getApiErrorMessage(error, "Failed to save changes");
toast.error("Save item failed", `Save item failed: ${message}`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -63,11 +67,12 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
const handleImageUpload = async (imageFile) => { const handleImageUpload = async (imageFile) => {
if (onImageUpdate) { if (onImageUpdate) {
try { try {
await onImageUpdate(item.id, itemName, quantity, imageFile); await onImageUpdate(item.id, itemName, quantity, imageFile, "edit_modal");
setShowImageModal(false); setShowImageModal(false);
} catch (error) { } catch (error) {
console.error("Failed to upload image:", error); console.error("Failed to upload image:", error);
alert("Failed to upload image"); const message = getApiErrorMessage(error, "Failed to upload image");
toast.error("Upload image failed", `Upload image failed: ${message}`);
} }
} }
}; };

View File

@ -0,0 +1,105 @@
import { createContext, useCallback, useEffect, useMemo, useRef, useState } from "react";
const MAX_ACTION_TOASTS = 5;
const DEFAULT_DURATION_MS = {
success: 3500,
info: 3500,
error: 5000,
};
export const ActionToastContext = createContext(null);
function createToastId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `toast_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
export function ActionToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const timerByIdRef = useRef(new Map());
const dismiss = useCallback((id) => {
const timer = timerByIdRef.current.get(id);
if (timer) {
clearTimeout(timer);
timerByIdRef.current.delete(id);
}
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const pushToast = useCallback(
(variant, title, message, options = {}) => {
const id = createToastId();
const durationMs = options.durationMs ?? DEFAULT_DURATION_MS[variant] ?? 3500;
const nextToast = {
id,
variant,
title: String(title || ""),
message: String(message || ""),
createdAt: Date.now(),
durationMs,
};
setToasts((prev) => {
const next = [...prev, nextToast];
if (next.length > MAX_ACTION_TOASTS) {
const oldest = next.shift();
if (oldest) {
const timer = timerByIdRef.current.get(oldest.id);
if (timer) {
clearTimeout(timer);
timerByIdRef.current.delete(oldest.id);
}
}
}
return next;
});
if (durationMs > 0) {
const timer = setTimeout(() => dismiss(id), durationMs);
timerByIdRef.current.set(id, timer);
}
return id;
},
[dismiss]
);
const success = useCallback(
(title, message, options) => pushToast("success", title, message, options),
[pushToast]
);
const error = useCallback(
(title, message, options) => pushToast("error", title, message, options),
[pushToast]
);
const info = useCallback(
(title, message, options) => pushToast("info", title, message, options),
[pushToast]
);
useEffect(() => {
return () => {
for (const timer of timerByIdRef.current.values()) {
clearTimeout(timer);
}
timerByIdRef.current.clear();
};
}, []);
const value = useMemo(
() => ({
toasts,
success,
error,
info,
dismiss,
}),
[toasts, success, error, info, dismiss]
);
return <ActionToastContext.Provider value={value}>{children}</ActionToastContext.Provider>;
}

View File

@ -0,0 +1,406 @@
import { createContext, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { updateItemImage } from "../api/list";
import {
deleteUploadJob,
getAllUploadJobs,
saveUploadJob,
} from "../lib/uploadQueueStorage";
const SUCCESS_DISMISS_DELAY_MS = 2500;
const NETWORK_TIMEOUT_MS = 90000;
export const IMAGE_UPLOAD_SUCCESS_EVENT = "upload-queue:image-upload-success";
export const UploadQueueContext = createContext({
uploads: [],
isOnline: true,
enqueueImageUpload: () => "",
retryUpload: () => {},
discardUpload: () => {},
});
function nowTs() {
return Date.now();
}
function createId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `upl_${nowTs()}_${Math.random().toString(16).slice(2)}`;
}
function classifyUploadError(error, isOnline) {
if (!isOnline || error?.code === "ERR_NETWORK" || error?.code === "ECONNABORTED") {
return "Network issue. Check your connection and retry.";
}
const responseMessage =
error?.response?.data?.error?.message ||
error?.response?.data?.message ||
error?.message;
return responseMessage || "Upload failed. Retry or discard.";
}
function matchesItemKey(a, b) {
return (
a.householdId === b.householdId &&
a.storeId === b.storeId &&
String(a.itemName || "").toLowerCase() === String(b.itemName || "").toLowerCase()
);
}
export function UploadQueueProvider({ children }) {
const [uploads, setUploads] = useState([]);
const [isOnline, setIsOnline] = useState(
typeof navigator === "undefined" ? true : navigator.onLine
);
const uploadsRef = useRef([]);
const processingRef = useRef(false);
const controllerByIdRef = useRef(new Map());
const successTimerByIdRef = useRef(new Map());
useEffect(() => {
uploadsRef.current = uploads;
}, [uploads]);
const removeUpload = useCallback((uploadId) => {
const timer = successTimerByIdRef.current.get(uploadId);
if (timer) {
clearTimeout(timer);
successTimerByIdRef.current.delete(uploadId);
}
const controller = controllerByIdRef.current.get(uploadId);
if (controller) {
controller.abort();
controllerByIdRef.current.delete(uploadId);
}
setUploads((prev) => prev.filter((upload) => upload.id !== uploadId));
deleteUploadJob(uploadId).catch((error) => {
console.error("[UploadQueue] Failed to delete upload job:", error);
});
}, []);
const updateUpload = useCallback((uploadId, updater) => {
let updatedUpload = null;
setUploads((prev) =>
prev.map((upload) => {
if (upload.id !== uploadId) {
return upload;
}
updatedUpload = {
...updater(upload),
updatedAt: nowTs(),
};
return updatedUpload;
})
);
if (updatedUpload) {
saveUploadJob(updatedUpload).catch((error) => {
console.error("[UploadQueue] Failed to persist upload job:", error);
});
}
return updatedUpload;
}, []);
useEffect(() => {
let isCancelled = false;
const hydrateUploads = async () => {
try {
const stored = await getAllUploadJobs();
if (isCancelled) return;
const hydrated = [];
for (const upload of stored) {
if (upload.status === "discarded") {
deleteUploadJob(upload.id).catch(() => {});
continue;
}
if (upload.status === "uploading") {
const interrupted = {
...upload,
status: "failed",
progress: 0,
lastError: "Upload interrupted. Retry or discard.",
updatedAt: nowTs(),
};
hydrated.push(interrupted);
saveUploadJob(interrupted).catch((error) => {
console.error("[UploadQueue] Failed to persist interrupted upload:", error);
});
continue;
}
hydrated.push(upload);
}
hydrated.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
setUploads(hydrated);
} catch (error) {
console.error("[UploadQueue] Failed to hydrate uploads:", error);
}
};
hydrateUploads();
return () => {
isCancelled = true;
};
}, []);
useEffect(() => {
const onOnline = () => setIsOnline(true);
const onOffline = () => setIsOnline(false);
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
};
}, []);
const processNextUpload = useCallback(async () => {
if (processingRef.current || !isOnline) {
return;
}
const queuedUpload = uploadsRef.current.find((upload) => upload.status === "queued");
if (!queuedUpload) {
return;
}
processingRef.current = true;
const controller = new AbortController();
controllerByIdRef.current.set(queuedUpload.id, controller);
updateUpload(queuedUpload.id, (current) => ({
...current,
status: "uploading",
progress: 0,
lastError: null,
attemptCount: (current.attemptCount || 0) + 1,
}));
try {
await updateItemImage(
queuedUpload.householdId,
queuedUpload.storeId,
queuedUpload.itemName,
queuedUpload.quantity,
queuedUpload.fileBlob,
{
signal: controller.signal,
timeoutMs: NETWORK_TIMEOUT_MS,
onUploadProgress: (event) => {
if (!event?.total || event.total <= 0) {
return;
}
const progress = Math.max(
1,
Math.min(99, Math.round((event.loaded / event.total) * 100))
);
updateUpload(queuedUpload.id, (current) => ({
...current,
status: "uploading",
progress,
}));
},
}
);
updateUpload(queuedUpload.id, (current) => ({
...current,
status: "success",
progress: 100,
lastError: null,
}));
window.dispatchEvent(
new CustomEvent(IMAGE_UPLOAD_SUCCESS_EVENT, {
detail: {
uploadId: queuedUpload.id,
householdId: queuedUpload.householdId,
storeId: queuedUpload.storeId,
itemName: queuedUpload.itemName,
localItemId: queuedUpload.localItemId || null,
},
})
);
} catch (error) {
if (error?.code !== "ERR_CANCELED") {
updateUpload(queuedUpload.id, (current) => ({
...current,
status: "failed",
progress: 0,
lastError: classifyUploadError(error, isOnline),
}));
}
} finally {
controllerByIdRef.current.delete(queuedUpload.id);
processingRef.current = false;
setTimeout(() => {
void processNextUpload();
}, 0);
}
}, [isOnline, updateUpload]);
useEffect(() => {
void processNextUpload();
}, [uploads, isOnline, processNextUpload]);
useEffect(() => {
const activeIds = new Set(uploads.map((upload) => upload.id));
for (const [id, timer] of successTimerByIdRef.current.entries()) {
if (!activeIds.has(id)) {
clearTimeout(timer);
successTimerByIdRef.current.delete(id);
}
}
for (const upload of uploads) {
if (upload.status !== "success") {
continue;
}
if (successTimerByIdRef.current.has(upload.id)) {
continue;
}
const timer = setTimeout(() => {
removeUpload(upload.id);
}, SUCCESS_DISMISS_DELAY_MS);
successTimerByIdRef.current.set(upload.id, timer);
}
}, [uploads, removeUpload]);
useEffect(() => {
return () => {
for (const timer of successTimerByIdRef.current.values()) {
clearTimeout(timer);
}
successTimerByIdRef.current.clear();
for (const controller of controllerByIdRef.current.values()) {
controller.abort();
}
controllerByIdRef.current.clear();
};
}, []);
const enqueueImageUpload = useCallback(
({
householdId,
storeId,
itemName,
quantity,
fileBlob,
fileName,
fileType,
fileSize,
source,
localItemId = null,
}) => {
const upload = {
id: createId(),
kind: "item_image_upload",
status: "queued",
householdId,
storeId,
itemName,
quantity,
fileBlob,
fileName,
fileType,
fileSize,
source,
localItemId,
progress: 0,
attemptCount: 0,
lastError: null,
createdAt: nowTs(),
updatedAt: nowTs(),
};
const toRemove = [];
setUploads((prev) => {
const next = [];
for (const current of prev) {
const isDedupCandidate =
current.kind === "item_image_upload" &&
["queued", "uploading", "failed"].includes(current.status) &&
matchesItemKey(current, upload);
if (isDedupCandidate) {
toRemove.push(current.id);
continue;
}
next.push(current);
}
next.push(upload);
return next;
});
for (const uploadId of toRemove) {
const activeController = controllerByIdRef.current.get(uploadId);
if (activeController) {
activeController.abort();
}
removeUpload(uploadId);
}
saveUploadJob(upload).catch((error) => {
console.error("[UploadQueue] Failed to save queued upload:", error);
});
return upload.id;
},
[removeUpload]
);
const retryUpload = useCallback(
(uploadId) => {
updateUpload(uploadId, (upload) => ({
...upload,
status: "queued",
progress: 0,
lastError: null,
}));
},
[updateUpload]
);
const discardUpload = useCallback(
(uploadId) => {
removeUpload(uploadId);
},
[removeUpload]
);
const value = useMemo(
() => ({
uploads,
isOnline,
enqueueImageUpload,
retryUpload,
discardUpload,
}),
[uploads, isOnline, enqueueImageUpload, retryUpload, discardUpload]
);
return <UploadQueueContext.Provider value={value}>{children}</UploadQueueContext.Provider>;
}

View File

@ -0,0 +1,10 @@
import { useContext } from "react";
import { ActionToastContext } from "../context/ActionToastContext";
export default function useActionToast() {
const context = useContext(ActionToastContext);
if (!context) {
throw new Error("useActionToast must be used within an ActionToastProvider");
}
return context;
}

View File

@ -0,0 +1,6 @@
import { useContext } from "react";
import { UploadQueueContext } from "../context/UploadQueueContext";
export default function useUploadQueue() {
return useContext(UploadQueueContext);
}

View File

@ -0,0 +1,8 @@
export default function getApiErrorMessage(error, fallbackMessage = "Unexpected error") {
return (
error?.response?.data?.error?.message ||
error?.response?.data?.message ||
error?.message ||
fallbackMessage
);
}

View File

@ -0,0 +1,58 @@
const DB_NAME = "costco-upload-queue";
const DB_VERSION = 1;
const STORE_NAME = "uploads";
function openUploadQueueDb() {
return new Promise((resolve, reject) => {
if (typeof indexedDB === "undefined") {
reject(new Error("IndexedDB is not available"));
return;
}
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error || new Error("Failed to open upload DB"));
});
}
function withStore(mode, handler) {
return openUploadQueueDb().then(
(db) =>
new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, mode);
const store = tx.objectStore(STORE_NAME);
const request = handler(store);
tx.oncomplete = () => {
db.close();
resolve(request?.result);
};
tx.onerror = () => {
db.close();
reject(tx.error || new Error("IndexedDB transaction failed"));
};
})
);
}
export function getAllUploadJobs() {
return withStore("readonly", (store) => store.getAll()).then((rows) =>
Array.isArray(rows) ? rows : []
);
}
export function saveUploadJob(job) {
return withStore("readwrite", (store) => store.put(job));
}
export function deleteUploadJob(id) {
return withStore("readwrite", (store) => store.delete(id));
}

View File

@ -2,17 +2,24 @@ import { useEffect, useState } from "react";
import { getAllUsers, updateRole } from "../api/users"; import { getAllUsers, updateRole } from "../api/users";
import StoreManagement from "../components/admin/StoreManagement"; import StoreManagement from "../components/admin/StoreManagement";
import UserRoleCard from "../components/common/UserRoleCard"; import UserRoleCard from "../components/common/UserRoleCard";
import useActionToast from "../hooks/useActionToast";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/UserRoleCard.css"; import "../styles/UserRoleCard.css";
import "../styles/pages/AdminPanel.css"; import "../styles/pages/AdminPanel.css";
export default function AdminPanel() { export default function AdminPanel() {
const toast = useActionToast();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [activeTab, setActiveTab] = useState("users"); const [activeTab, setActiveTab] = useState("users");
async function loadUsers() { async function loadUsers() {
const allUsers = await getAllUsers(); try {
console.log("Users found:", users); const allUsers = await getAllUsers();
setUsers(allUsers.data); setUsers(allUsers.data);
} catch (error) {
const message = getApiErrorMessage(error, "Failed to load users");
toast.error("Load users failed", `Load users failed: ${message}`);
}
} }
useEffect(() => { useEffect(() => {
@ -20,10 +27,22 @@ export default function AdminPanel() {
}, []); }, []);
const changeRole = async (id, role) => { const changeRole = async (id, role) => {
const updated = await updateRole(id, role); const selectedUser = users.find((user) => user.id === id);
if (updated.status !== 200) return; const username = selectedUser?.username || `user #${id}`;
loadUsers();
} try {
const updated = await updateRole(id, role);
if (updated.status !== 200) {
toast.error("Update role failed", "Update role failed: unexpected response");
return;
}
toast.success("Updated user role", `Updated role for ${username} to ${role}`);
loadUsers();
} catch (error) {
const message = getApiErrorMessage(error, "Failed to update user role");
toast.error("Update role failed", `Update role failed: ${message}`);
}
};
return ( return (
<div className="admin-panel-body"> <div className="admin-panel-body">
@ -62,5 +81,5 @@ export default function AdminPanel() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -26,7 +26,9 @@ import { HouseholdContext } from "../context/HouseholdContext";
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext"; import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
import { SettingsContext } from "../context/SettingsContext"; import { SettingsContext } from "../context/SettingsContext";
import { StoreContext } from "../context/StoreContext"; import { StoreContext } from "../context/StoreContext";
import useActionToast from "../hooks/useActionToast";
import useUploadQueue from "../hooks/useUploadQueue"; import useUploadQueue from "../hooks/useUploadQueue";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/GroceryList.css"; import "../styles/pages/GroceryList.css";
import { findSimilarItems } from "../utils/stringSimilarity"; import { findSimilarItems } from "../utils/stringSimilarity";
@ -37,6 +39,7 @@ export default function GroceryList() {
const { activeHousehold } = useContext(HouseholdContext); const { activeHousehold } = useContext(HouseholdContext);
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext); const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
const { settings } = useContext(SettingsContext); const { settings } = useContext(SettingsContext);
const toast = useActionToast();
const { enqueueImageUpload } = useUploadQueue(); const { enqueueImageUpload } = useUploadQueue();
const navigate = useNavigate(); const navigate = useNavigate();
@ -255,40 +258,44 @@ export default function GroceryList() {
// === Item Addition Handlers === // === Item Addition Handlers ===
const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => { const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => {
const normalizedItemName = itemName.trim().toLowerCase(); try {
if (!normalizedItemName) return; const normalizedItemName = itemName.trim().toLowerCase();
if (!activeHousehold?.id || !activeStore?.id) return; if (!normalizedItemName) return;
if (!activeHousehold?.id || !activeStore?.id) return;
const allItems = [...items, ...recentlyBoughtItems]; const allItems = [...items, ...recentlyBoughtItems];
const existingLocalItem = allItems.find( const existingLocalItem = allItems.find(
(item) => String(item.item_name || "").toLowerCase() === normalizedItemName (item) => String(item.item_name || "").toLowerCase() === normalizedItemName
); );
if (existingLocalItem) { if (existingLocalItem) {
await processItemAddition(itemName, quantity, {
existingItem: existingLocalItem,
addedForUserId
});
return;
}
const similar = findSimilarItems(itemName, allItems, 70);
if (similar.length > 0) {
setSimilarItemSuggestion({
originalName: itemName,
suggestedItem: similar[0],
quantity,
addedForUserId
});
setShowSimilarModal(true);
return;
}
const shouldSkipLookup = buttonText === "Create + Add";
await processItemAddition(itemName, quantity, { await processItemAddition(itemName, quantity, {
existingItem: existingLocalItem, skipLookup: shouldSkipLookup,
addedForUserId addedForUserId
}); });
return; } catch (error) {
console.error("Failed to process add item flow:", error);
} }
const similar = findSimilarItems(itemName, allItems, 70);
if (similar.length > 0) {
setSimilarItemSuggestion({
originalName: itemName,
suggestedItem: similar[0],
quantity,
addedForUserId
});
setShowSimilarModal(true);
return;
}
const shouldSkipLookup = buttonText === "Create + Add";
await processItemAddition(itemName, quantity, {
skipLookup: shouldSkipLookup,
addedForUserId
});
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]); }, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
@ -325,26 +332,33 @@ export default function GroceryList() {
}); });
setShowConfirmAddExisting(true); setShowConfirmAddExisting(true);
} else if (existingItem) { } else if (existingItem) {
await addItem( try {
activeHousehold.id, await addItem(
activeStore.id, activeHousehold.id,
itemName, activeStore.id,
quantity, itemName,
null, quantity,
null, null,
addedForUserId null,
); addedForUserId
setSuggestions([]); );
setButtonText("Add Item"); setSuggestions([]);
setButtonText("Add Item");
toast.success("Added item", `Added item ${itemName}`);
// Reload lists to reflect the changes // Reload lists to reflect the changes
await loadItems(); await loadItems();
await loadRecentlyBought(); await loadRecentlyBought();
} catch (error) {
const message = getApiErrorMessage(error, "Failed to add item");
toast.error("Add item failed", `Add item failed: ${message}`);
throw error;
}
} else { } else {
setPendingItem({ itemName, quantity, addedForUserId }); setPendingItem({ itemName, quantity, addedForUserId });
setShowAddDetailsModal(true); setShowAddDetailsModal(true);
} }
}, [activeHousehold?.id, activeStore?.id, loadItems]); }, [activeHousehold?.id, activeStore?.id, loadItems, loadRecentlyBought, toast]);
// === Similar Item Modal Handlers === // === Similar Item Modal Handlers ===
@ -407,11 +421,14 @@ export default function GroceryList() {
setSuggestions([]); setSuggestions([]);
setButtonText("Add Item"); setButtonText("Add Item");
toast.success("Updated item quantity", `Updated item ${itemName}`);
} catch (error) { } catch (error) {
console.error("Failed to update item:", error); console.error("Failed to update item:", error);
const message = getApiErrorMessage(error, "Failed to update item");
toast.error("Update item failed", `Update item failed: ${message}`);
await loadItems(); await loadItems();
} }
}, []); }, [activeHousehold?.id, activeStore?.id, confirmAddExistingData, loadItems, toast]);
// === Add Details Modal Handlers === // === Add Details Modal Handlers ===
@ -434,6 +451,7 @@ export default function GroceryList() {
if (classification) { if (classification) {
// Apply classification if provided // Apply classification if provided
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification); await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification);
toast.success("Updated item classification", `Updated classification for ${pendingItem.itemName}`);
} }
// Fetch the newly added item // Fetch the newly added item
@ -448,6 +466,7 @@ export default function GroceryList() {
// Add to state // Add to state
if (newItem) { if (newItem) {
setItems(prevItems => [...prevItems, newItem]); setItems(prevItems => [...prevItems, newItem]);
toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`);
if (imageFile) { if (imageFile) {
enqueueImageUpload({ enqueueImageUpload({
@ -462,13 +481,15 @@ export default function GroceryList() {
source: "add_details", source: "add_details",
localItemId: newItem.id, localItemId: newItem.id,
}); });
toast.info("Queued image upload", `Queued image upload for ${newItem.item_name || pendingItem.itemName}`);
} }
} }
} catch (error) { } catch (error) {
console.error("Failed to add item:", error); console.error("Failed to add item:", error);
alert("Failed to add item. Please try again."); const message = getApiErrorMessage(error, "Failed to add item");
toast.error("Add item failed", `Add item failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload]); }, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]);
const handleAddDetailsSkip = useCallback(async () => { const handleAddDetailsSkip = useCallback(async () => {
if (!pendingItem) return; if (!pendingItem) return;
@ -496,12 +517,14 @@ export default function GroceryList() {
if (newItem) { if (newItem) {
setItems(prevItems => [...prevItems, newItem]); setItems(prevItems => [...prevItems, newItem]);
toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`);
} }
} catch (error) { } catch (error) {
console.error("Failed to add item:", error); console.error("Failed to add item:", error);
alert("Failed to add item. Please try again."); const message = getApiErrorMessage(error, "Failed to add item");
toast.error("Add item failed", `Add item failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, pendingItem]); }, [activeHousehold?.id, activeStore?.id, pendingItem, toast]);
const handleAddDetailsCancel = useCallback(() => { const handleAddDetailsCancel = useCallback(() => {
@ -519,23 +542,29 @@ export default function GroceryList() {
const item = items.find(i => i.id === id); const item = items.find(i => i.id === id);
if (!item) return; if (!item) return;
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true); try {
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
// If buying full quantity, remove from list // If buying full quantity, remove from list
if (quantity >= item.quantity) { if (quantity >= item.quantity) {
setItems(prevItems => prevItems.filter(item => item.id !== id)); setItems(prevItems => prevItems.filter((existingItem) => existingItem.id !== id));
} else { } else {
// If partial, fetch updated item // If partial, fetch updated item
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name); const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
const updatedItem = response.data; const updatedItem = response.data;
setItems(prevItems => setItems((prevItems) =>
prevItems.map(i => i.id === id ? updatedItem : i) prevItems.map((existingItem) => (existingItem.id === id ? updatedItem : existingItem))
); );
}
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
loadRecentlyBought();
} catch (error) {
const message = getApiErrorMessage(error, "Failed to mark item as bought");
toast.error("Mark item bought failed", `Mark item bought failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, items, toast]);
loadRecentlyBought();
}, [activeHousehold?.id, activeStore?.id, items]);
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => { const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
if (!activeHousehold?.id || !activeStore?.id) return; if (!activeHousehold?.id || !activeStore?.id) return;
@ -554,11 +583,13 @@ export default function GroceryList() {
source, source,
localItemId: id, localItemId: id,
}); });
toast.info("Queued image upload", `Queued image upload for ${itemName}`);
} catch (error) { } catch (error) {
console.error("Failed to add image:", error); console.error("Failed to add image:", error);
alert("Failed to add image. Please try again."); const message = getApiErrorMessage(error, "Failed to add image");
toast.error("Add image failed", `Add image failed: ${message}`);
} }
}, [activeHousehold?.id, activeStore?.id, enqueueImageUpload]); }, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]);
const handleLongPress = useCallback(async (item) => { const handleLongPress = useCallback(async (item) => {
@ -605,11 +636,14 @@ export default function GroceryList() {
item.id === id ? { ...item, ...updatedItem } : item item.id === id ? { ...item, ...updatedItem } : item
) )
); );
toast.success("Updated item", `Updated item ${itemName}`);
} catch (error) { } catch (error) {
console.error("Failed to update item:", error); console.error("Failed to update item:", error);
const message = getApiErrorMessage(error, "Failed to update item");
toast.error("Update item failed", `Update item failed: ${message}`);
throw error; throw error;
} }
}, [activeHousehold?.id, activeStore?.id]); }, [activeHousehold?.id, activeStore?.id, toast]);
const handleEditCancel = useCallback(() => { const handleEditCancel = useCallback(() => {

View File

@ -0,0 +1,158 @@
import { useContext, useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { acceptInviteLink, getInviteLinkSummary } from "../api/households";
import { AuthContext } from "../context/AuthContext";
import { HouseholdContext } from "../context/HouseholdContext";
import useActionToast from "../hooks/useActionToast";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/InviteLink.css";
function humanizeStatus(code) {
switch (code) {
case "ALREADY_MEMBER":
return "You are already a member of this group.";
case "PENDING":
return "Your join request is already pending approval.";
case "REVOKED":
return "This invite link has been revoked.";
case "EXPIRED":
return "This invite link has expired.";
case "USED":
return "This invite link has already been used.";
case "NOT_ACCEPTING":
return "This group is not accepting new members right now.";
default:
return "";
}
}
export default function InviteLink() {
const { token } = useParams();
const navigate = useNavigate();
const { token: authToken } = useContext(AuthContext);
const { refreshHouseholds } = useContext(HouseholdContext);
const toast = useActionToast();
const [loading, setLoading] = useState(true);
const [joining, setJoining] = useState(false);
const [link, setLink] = useState(null);
const [error, setError] = useState("");
const [message, setMessage] = useState("");
useEffect(() => {
const loadSummary = async () => {
if (!token) return;
setLoading(true);
setError("");
setMessage("");
try {
const response = await getInviteLinkSummary(token);
setLink(response.data.link);
} catch (err) {
setError(err.response?.data?.error?.message || "Invite link not found");
} finally {
setLoading(false);
}
};
loadSummary();
}, [token]);
const blockedCode = useMemo(() => {
if (!link) return null;
if (link.viewerStatus === "ALREADY_MEMBER") return "ALREADY_MEMBER";
if (link.viewerStatus === "PENDING") return "PENDING";
if (link.status === "REVOKED") return "REVOKED";
if (link.status === "EXPIRED") return "EXPIRED";
if (link.status === "USED") return "USED";
if (link.active_policy === "NOT_ACCEPTING") return "NOT_ACCEPTING";
return null;
}, [link]);
const handleJoin = async () => {
if (!token) return;
setJoining(true);
setError("");
setMessage("");
try {
const response = await acceptInviteLink(token);
const result = response.data.result;
if (result.status === "JOINED") {
setMessage(`Joined ${result.group.name}. Redirecting...`);
toast.success("Joined group", `Joined group ${result.group.name}`);
} else if (result.status === "PENDING") {
setMessage(`Request sent to join ${result.group.name}. Redirecting...`);
toast.info("Join request sent", `Request sent for ${result.group.name}`);
} else {
setMessage(`You are already a member of ${result.group.name}. Redirecting...`);
toast.info("Already a member", `Already a member of ${result.group.name}`);
}
await refreshHouseholds();
window.setTimeout(() => navigate("/"), 1200);
} catch (err) {
const message = getApiErrorMessage(err, "Failed to process invite");
setError(message);
toast.error("Join invite failed", `Join invite failed: ${message}`);
} finally {
setJoining(false);
}
};
if (loading) {
return (
<div className="invite-link-page">
<div className="invite-card">
<h1>Invite Link</h1>
<p>Loading invite details...</p>
</div>
</div>
);
}
if (error || !link) {
return (
<div className="invite-link-page">
<div className="invite-card">
<h1>Invite Link</h1>
<p className="invite-error">{error || "Invite link not found"}</p>
</div>
</div>
);
}
return (
<div className="invite-link-page">
<div className="invite-card">
<h1>Join {link.group_name}</h1>
<p className="invite-meta">Invite status: {link.status}</p>
{message && <p className="invite-success">{message}</p>}
{!message && blockedCode && <p className="invite-error">{humanizeStatus(blockedCode)}</p>}
{!message && !blockedCode && !authToken && (
<p className="invite-meta">Sign in or register to join this group.</p>
)}
{!message && !authToken && (
<div className="invite-actions">
<Link className="invite-btn" to={`/login?next=/invite/${link.token}`}>
Sign In
</Link>
<Link className="invite-btn invite-btn-secondary" to={`/register?next=/invite/${link.token}`}>
Register
</Link>
</div>
)}
{!message && authToken && !blockedCode && (
<div className="invite-actions">
<button className="invite-btn" onClick={handleJoin} disabled={joining}>
{joining ? "Joining..." : "Join Group"}
</button>
</div>
)}
</div>
</div>
);
}

View File

@ -1,13 +1,17 @@
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { Link } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { loginRequest } from "../api/auth"; import { loginRequest } from "../api/auth";
import ErrorMessage from "../components/common/ErrorMessage"; import ErrorMessage from "../components/common/ErrorMessage";
import FormInput from "../components/common/FormInput"; import FormInput from "../components/common/FormInput";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
import useActionToast from "../hooks/useActionToast";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/Login.css"; import "../styles/pages/Login.css";
export default function Login() { export default function Login() {
const navigate = useNavigate();
const { login } = useContext(AuthContext); const { login } = useContext(AuthContext);
const toast = useActionToast();
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
@ -20,9 +24,12 @@ export default function Login() {
try { try {
const data = await loginRequest(username, password); const data = await loginRequest(username, password);
login(data); login(data);
window.location.href = "/"; toast.success("Logged in", `Welcome back ${data?.username || username}`);
navigate("/");
} catch (err) { } catch (err) {
setError(err.response?.data?.message || "Login failed"); const message = getApiErrorMessage(err, "Login failed");
setError(message);
toast.error("Login failed", `Login failed: ${message}`);
} }
}; };

View File

@ -5,11 +5,14 @@ import { checkIfUserExists } from "../api/users";
import ErrorMessage from "../components/common/ErrorMessage"; import ErrorMessage from "../components/common/ErrorMessage";
import FormInput from "../components/common/FormInput"; import FormInput from "../components/common/FormInput";
import { AuthContext } from "../context/AuthContext"; import { AuthContext } from "../context/AuthContext";
import useActionToast from "../hooks/useActionToast";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/Register.css"; import "../styles/pages/Register.css";
export default function Register() { export default function Register() {
const navigate = useNavigate(); const navigate = useNavigate();
const { login } = useContext(AuthContext); const { login } = useContext(AuthContext);
const toast = useActionToast();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@ -47,12 +50,16 @@ export default function Register() {
try { try {
await registerRequest(username, password, name); await registerRequest(username, password, name);
toast.success("Registered account", `Created account for ${username}`);
const data = await loginRequest(username, password); const data = await loginRequest(username, password);
login(data); login(data);
toast.success("Logged in", `Welcome ${data?.username || username}`);
setSuccess("Account created! Redirecting to the grocery list..."); setSuccess("Account created! Redirecting to the grocery list...");
setTimeout(() => navigate("/"), 2000); setTimeout(() => navigate("/"), 2000);
} catch (err) { } catch (err) {
setError(err.response?.data?.message || "Registration failed"); const message = getApiErrorMessage(err, "Registration failed");
setError(message);
toast.error("Registration failed", `Registration failed: ${message}`);
setTimeout(() => setError(""), 1000); setTimeout(() => setError(""), 1000);
} }
}; };

View File

@ -1,11 +1,14 @@
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users"; import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users";
import { SettingsContext } from "../context/SettingsContext"; import { SettingsContext } from "../context/SettingsContext";
import useActionToast from "../hooks/useActionToast";
import getApiErrorMessage from "../lib/getApiErrorMessage";
import "../styles/pages/Settings.css"; import "../styles/pages/Settings.css";
export default function Settings() { export default function Settings() {
const { settings, updateSettings, resetSettings } = useContext(SettingsContext); const { settings, updateSettings, resetSettings } = useContext(SettingsContext);
const toast = useActionToast();
const [activeTab, setActiveTab] = useState("appearance"); const [activeTab, setActiveTab] = useState("appearance");
const tabsRef = useRef(null); const tabsRef = useRef(null);
const [showLeftArrow, setShowLeftArrow] = useState(false); const [showLeftArrow, setShowLeftArrow] = useState(false);
@ -75,11 +78,14 @@ export default function Settings() {
try { try {
await updateCurrentUser(displayName); await updateCurrentUser(displayName);
setAccountMessage({ type: "success", text: "Display name updated successfully!" }); setAccountMessage({ type: "success", text: "Display name updated successfully!" });
toast.success("Updated display name", `Updated display name to ${displayName}`);
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to update display name");
setAccountMessage({ setAccountMessage({
type: "error", type: "error",
text: error.response?.data?.error || "Failed to update display name" text: message
}); });
toast.error("Update display name failed", `Update display name failed: ${message}`);
} finally { } finally {
setLoadingProfile(false); setLoadingProfile(false);
} }
@ -105,14 +111,17 @@ export default function Settings() {
try { try {
await changePassword(currentPassword, newPassword); await changePassword(currentPassword, newPassword);
setAccountMessage({ type: "success", text: "Password changed successfully!" }); setAccountMessage({ type: "success", text: "Password changed successfully!" });
toast.success("Changed password", "Changed password successfully");
setCurrentPassword(""); setCurrentPassword("");
setNewPassword(""); setNewPassword("");
setConfirmPassword(""); setConfirmPassword("");
} catch (error) { } catch (error) {
const message = getApiErrorMessage(error, "Failed to change password");
setAccountMessage({ setAccountMessage({
type: "error", type: "error",
text: error.response?.data?.error || "Failed to change password" text: message
}); });
toast.error("Change password failed", `Change password failed: ${message}`);
} finally { } finally {
setLoadingPassword(false); setLoadingPassword(false);
} }

View File

@ -33,7 +33,7 @@
.add-item-form-assignee-toggle { .add-item-form-assignee-toggle {
flex: 0 0 auto; flex: 0 0 auto;
width: 134px; width: 112px;
margin: 0; margin: 0;
} }
@ -201,7 +201,7 @@
} }
.add-item-form-assignee-toggle { .add-item-form-assignee-toggle {
width: 120px; width: 100px;
} }
.add-item-form-quantity-control { .add-item-form-quantity-control {

View File

@ -0,0 +1,144 @@
.confirm-slide-overlay {
position: fixed;
inset: 0;
z-index: var(--z-modal);
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-md);
background: var(--modal-backdrop-bg);
}
.confirm-slide-modal {
width: 100%;
max-width: 420px;
background: var(--modal-bg);
border: var(--border-width-thin) solid var(--color-border-light);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-xl);
padding: var(--spacing-lg);
}
.confirm-slide-title {
margin: 0;
font-size: var(--font-size-xl);
color: var(--color-text-primary);
}
.confirm-slide-description {
margin: var(--spacing-sm) 0 0;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.confirm-slide-track-wrap {
margin-top: var(--spacing-lg);
}
.confirm-slide-helper {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.confirm-slide-track {
position: relative;
margin-top: var(--spacing-sm);
height: 40px;
border-radius: 999px;
border: var(--border-width-thin) solid var(--color-border-medium);
background: var(--color-bg-body);
overflow: hidden;
user-select: none;
}
.confirm-slide-track.is-active {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.confirm-slide-progress {
position: absolute;
inset: 0 auto 0 0;
border-radius: 999px;
background: rgba(30, 144, 255, 0.2);
min-width: 40px;
z-index: 1;
pointer-events: none;
}
.confirm-slide-ready {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0.9);
opacity: 0;
z-index: 2;
font-size: var(--font-size-xs);
font-weight: var(--font-weight-semibold);
color: var(--color-primary);
white-space: nowrap;
transition: var(--transition-fast);
pointer-events: none;
}
.confirm-slide-ready.is-visible {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.confirm-slide-handle {
position: absolute;
top: 0;
left: 0;
z-index: 3;
width: 40px;
height: 40px;
margin: 0;
padding: 0;
border-radius: 50%;
border: var(--border-width-thin) solid var(--color-primary);
background: var(--modal-bg);
color: var(--color-text-primary);
font-size: 1.1rem;
font-weight: var(--font-weight-semibold);
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
transition: box-shadow var(--transition-fast), border-color var(--transition-fast);
touch-action: none;
}
.confirm-slide-handle:active {
cursor: grabbing;
}
.confirm-slide-handle.is-active {
border-color: var(--color-primary-dark);
box-shadow: 0 0 0 2px rgba(30, 144, 255, 0.2);
}
.confirm-slide-footer {
margin-top: var(--spacing-lg);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-md);
}
.confirm-slide-label {
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.confirm-slide-cancel {
width: auto;
margin: 0;
}
@media (max-width: 480px) {
.confirm-slide-modal {
padding: var(--spacing-md);
}
}

View File

@ -6,9 +6,11 @@
.store-tabs-container { .store-tabs-container {
display: flex; display: flex;
justify-content: center;
gap: 0.25rem; gap: 0.25rem;
overflow-x: auto; overflow-x: auto;
padding: 0.5rem 1rem 0; padding: 0.5rem 1rem 0;
width: 100%;
} }
.store-tabs-container::-webkit-scrollbar { .store-tabs-container::-webkit-scrollbar {
@ -23,6 +25,7 @@
.store-tab { .store-tab {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
background: transparent; background: transparent;
@ -34,6 +37,7 @@
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
white-space: nowrap; white-space: nowrap;
text-align: center;
} }
.store-tab:hover { .store-tab:hover {

View File

@ -56,6 +56,12 @@
font-weight: var(--font-weight-semibold); font-weight: var(--font-weight-semibold);
} }
.tbg-button.tbg-size-xxs {
padding: 0.25rem 0.38rem;
font-size: 0.68rem;
font-weight: var(--font-weight-semibold);
}
.tbg-button.is-active { .tbg-button.is-active {
color: var(--color-text-inverse); color: var(--color-text-inverse);
background: transparent; background: transparent;

View File

@ -0,0 +1,123 @@
.upload-toaster {
position: fixed;
right: 1rem;
bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
z-index: 1200;
max-width: min(90vw, 22rem);
}
.upload-toast {
background: var(--color-bg-surface);
color: var(--color-text-primary);
border: 1px solid var(--color-border-light);
border-radius: 10px;
padding: 0.7rem 0.8rem;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
}
.upload-toast-title {
font-size: 0.95rem;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-toast-status {
margin-top: 0.15rem;
font-size: 0.82rem;
color: var(--color-text-secondary);
}
.upload-toast-progress {
margin-top: 0.45rem;
width: 100%;
height: 6px;
border-radius: 999px;
background: var(--color-border-light);
overflow: hidden;
}
.upload-toast-progress-fill {
height: 100%;
width: 0;
background: var(--color-primary);
transition: width 0.2s ease;
}
.upload-toast-actions {
margin-top: 0.55rem;
display: flex;
gap: 0.4rem;
}
.upload-toast-actions button {
border: 1px solid var(--color-border-light);
background: var(--color-bg-surface);
color: var(--color-text-primary);
border-radius: 6px;
padding: 0.3rem 0.55rem;
font-size: 0.8rem;
cursor: pointer;
}
.upload-toast-actions button:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.upload-toast-success .upload-toast-progress-fill {
background: var(--color-success);
}
.upload-toast-failed .upload-toast-progress-fill {
background: var(--color-danger);
}
.action-toast {
border-left: 4px solid var(--color-primary);
}
.action-toast-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.action-toast-close {
border: none;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
padding: 0.1rem 0.2rem;
}
.action-toast-close:hover {
color: var(--color-text-primary);
}
.action-toast-success {
border-left-color: var(--color-success);
}
.action-toast-error {
border-left-color: var(--color-danger);
}
.action-toast-info {
border-left-color: var(--color-primary);
}
@media (max-width: 640px) {
.upload-toaster {
right: 0.6rem;
left: 0.6rem;
max-width: none;
}
}

View File

@ -85,6 +85,78 @@
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.section-error {
color: var(--danger);
margin: 0 0 0.75rem 0;
}
.manage-household-join-policy-toggle {
margin-bottom: 1rem;
}
.invite-controls {
display: flex;
gap: 0.8rem;
align-items: end;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.invite-controls label {
display: flex;
flex-direction: column;
gap: 0.3rem;
color: var(--text-primary);
font-size: 0.9rem;
}
.invite-controls select {
min-width: 120px;
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.45rem 0.6rem;
background: var(--background);
color: var(--text-primary);
}
.invite-links-list {
display: flex;
flex-direction: column;
gap: 0.8rem;
}
.invite-link-card {
border: 1px solid var(--border);
background: var(--background);
border-radius: 8px;
padding: 0.9rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.invite-link-token,
.invite-link-meta {
margin: 0;
}
.invite-link-token {
font-weight: 600;
}
.invite-link-meta {
color: var(--text-secondary);
font-size: 0.85rem;
}
.invite-link-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Members Section */ /* Members Section */
.members-list { .members-list {
display: flex; display: flex;
@ -244,4 +316,23 @@
text-align: center; text-align: center;
width: 100%; width: 100%;
} }
.invite-controls {
flex-direction: column;
align-items: stretch;
}
.invite-controls label,
.invite-controls select,
.invite-controls button {
width: 100%;
}
.invite-link-actions {
width: 100%;
}
.invite-link-actions button {
flex: 1;
}
} }

View File

@ -0,0 +1,77 @@
.invite-link-page {
min-height: calc(100vh - 80px);
display: flex;
justify-content: center;
align-items: center;
padding: 2rem 1rem;
}
.invite-card {
width: 100%;
max-width: 540px;
border: 1px solid var(--border);
background: var(--card-bg);
border-radius: 12px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.invite-card h1 {
margin: 0;
font-size: 1.5rem;
}
.invite-meta {
margin: 0;
color: var(--text-secondary);
}
.invite-error {
margin: 0;
color: var(--danger);
}
.invite-success {
margin: 0;
color: var(--success, #2e7d32);
}
.invite-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.invite-btn {
border: none;
border-radius: 8px;
padding: 0.65rem 1rem;
background: var(--primary);
color: #fff;
text-decoration: none;
cursor: pointer;
font-weight: 600;
}
.invite-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.invite-btn-secondary {
background: var(--card-hover);
color: var(--text-primary);
}
@media (max-width: 640px) {
.invite-card {
padding: 1.1rem;
}
.invite-btn {
width: 100%;
text-align: center;
}
}

View File

@ -0,0 +1,10 @@
import { expect, test } from "@playwright/test";
test("redirects unauthenticated users to login", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("heading", { name: "Login" })).toBeVisible();
await expect(page.getByPlaceholder("Username")).toBeVisible();
await expect(page.getByPlaceholder("Password")).toBeVisible();
});

View File

@ -0,0 +1,272 @@
import { expect, test } from "@playwright/test";
function seedAuthStorage(page: import("@playwright/test").Page) {
return page.addInitScript(() => {
localStorage.setItem("token", "test-token");
localStorage.setItem("userId", "1");
localStorage.setItem("role", "admin");
localStorage.setItem("username", "toast-user");
});
}
async function mockConfig(page: import("@playwright/test").Page) {
await page.route("**/config", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
maxFileSizeMB: 20,
maxImageDimension: 800,
imageQuality: 85,
}),
});
});
}
test("login failure shows inline error and error toast", async ({ page }) => {
await mockConfig(page);
await page.route("**/auth/login", async (route) => {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ message: "Invalid credentials" }),
});
});
await page.goto("/login");
await page.getByPlaceholder("Username").fill("bad-user");
await page.getByPlaceholder("Password").fill("bad-password");
await page.getByRole("button", { name: "Login" }).click();
await expect(page.getByText("Invalid credentials")).toBeVisible();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Login failed");
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Invalid credentials");
});
test("manage stores add success shows success toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
let linkedStoreIds = [10];
const allStores = [
{ id: 10, name: "Costco North", location: "North", is_default: true },
{ id: 11, name: "Costco South", location: "South", is_default: false },
];
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Toast Home", role: "admin", invite_code: "ABCD1234" }]),
});
});
await page.route("**/stores/household/1", async (route) => {
const request = route.request();
if (request.method() === "GET") {
const payload = linkedStoreIds.map((id, index) => {
const store = allStores.find((candidate) => candidate.id === id);
return {
...store,
is_default: index === 0,
};
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(payload),
});
return;
}
if (request.method() === "POST") {
const body = request.postDataJSON() as { storeId?: number };
if (body.storeId && !linkedStoreIds.includes(body.storeId)) {
linkedStoreIds = [...linkedStoreIds, body.storeId];
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
await route.fallback();
});
await page.route("**/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(allStores),
});
});
await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click();
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Added store");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Costco South");
});
test("manage stores add failure shows normalized error toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
const allStores = [
{ id: 10, name: "Costco North", location: "North", is_default: true },
{ id: 11, name: "Costco South", location: "South", is_default: false },
];
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Toast Home", role: "admin", invite_code: "ABCD1234" }]),
});
});
await page.route("**/stores/household/1", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 10, name: "Costco North", location: "North", is_default: true }]),
});
return;
}
if (request.method() === "POST") {
await route.fulfill({
status: 400,
contentType: "application/json",
body: JSON.stringify({
error: { message: "Store already linked to household" },
}),
});
return;
}
await route.fallback();
});
await page.route("**/stores", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(allStores),
});
});
await page.goto("/manage?tab=stores");
await page.getByRole("button", { name: "+ Add Store" }).click();
await page.locator(".available-store-card").filter({ hasText: "Costco South" }).getByRole("button", { name: "Add" }).click();
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Add store failed");
await expect(page.locator(".action-toast.action-toast-error")).toContainText("Store already linked to household");
});
test("invite accept JOINED shows success toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await page.route("**/api/invite-links/toast-token", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
link: {
token: "toast-token",
status: "ACTIVE",
viewerStatus: null,
active_policy: "AUTO_ACCEPT",
group_name: "Toast Group",
},
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
result: {
status: "JOINED",
group: { name: "Toast Group" },
},
}),
});
});
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Toast Home", role: "member", invite_code: "ABCD1234" }]),
});
});
await page.goto("/invite/toast-token");
await page.getByRole("button", { name: "Join Group" }).click();
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Joined group");
await expect(page.locator(".action-toast.action-toast-success")).toContainText("Toast Group");
});
test("invite accept PENDING shows info toast", async ({ page }) => {
await seedAuthStorage(page);
await mockConfig(page);
await page.route("**/api/invite-links/pending-token", async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
link: {
token: "pending-token",
status: "ACTIVE",
viewerStatus: null,
active_policy: "APPROVAL_REQUIRED",
group_name: "Pending Group",
},
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
result: {
status: "PENDING",
group: { name: "Pending Group" },
},
}),
});
});
await page.route("**/households", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([{ id: 1, name: "Toast Home", role: "member", invite_code: "ABCD1234" }]),
});
});
await page.goto("/invite/pending-token");
await page.getByRole("button", { name: "Join Group" }).click();
await expect(page.locator(".action-toast.action-toast-info")).toContainText("Join request sent");
await expect(page.locator(".action-toast.action-toast-info")).toContainText("Pending Group");
});

5
jest.config.cjs Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
testEnvironment: "node",
roots: ["<rootDir>/backend/tests"],
clearMocks: true,
};

View File

@ -2,7 +2,14 @@
"scripts": { "scripts": {
"db:migrate": "node scripts/db-migrate.js", "db:migrate": "node scripts/db-migrate.js",
"db:migrate:status": "node scripts/db-migrate-status.js", "db:migrate:status": "node scripts/db-migrate-status.js",
"db:migrate:verify": "node scripts/db-migrate-verify.js" "db:migrate:verify": "node scripts/db-migrate-verify.js",
"db:migrate:new": "node scripts/db-migrate-new.js",
"db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write",
"db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale",
"test": "jest --runInBand",
"test:e2e": "npm --prefix frontend run test:e2e --",
"test:e2e:headed": "npm --prefix frontend run test:e2e:headed --",
"test:e2e:ui": "npm --prefix frontend run test:e2e:ui --"
}, },
"devDependencies": { "devDependencies": {
"cross-env": "^10.1.0", "cross-env": "^10.1.0",

View File

@ -5,5 +5,11 @@ This directory is the canonical location for SQL migrations.
- Use `npm run db:migrate` to apply pending migrations. - Use `npm run db:migrate` to apply pending migrations.
- Use `npm run db:migrate:status` to view applied/pending migrations. - Use `npm run db:migrate:status` to view applied/pending migrations.
- Use `npm run db:migrate:verify` to fail when pending migrations exist. - Use `npm run db:migrate:verify` to fail when pending migrations exist.
- Use `npm run db:migrate:new -- <migration-name>` to create a new migration file.
Do not place new canonical migrations under `backend/migrations`. Do not place new canonical migrations under `backend/migrations`.
## Stale baseline
- `stale-files.json` lists canonical SQL files intentionally treated as stale.
- Files listed there are skipped by migration commands by default.
- Add only genuinely new migration files when schema changes are required.

View File

@ -1,20 +1,8 @@
# Database Migration: Add Image Support
Run these SQL commands on your PostgreSQL database:
```sql
-- Add image columns to grocery_list table -- Add image columns to grocery_list table
ALTER TABLE grocery_list ALTER TABLE grocery_list
ADD COLUMN item_image BYTEA, ADD COLUMN IF NOT EXISTS item_image BYTEA,
ADD COLUMN image_mime_type VARCHAR(50); ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50);
-- Optional: Add index for faster queries when filtering by items with images -- Index to speed up queries that filter by rows with images.
CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL)); CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image
``` ON grocery_list ((item_image IS NOT NULL));
## To Verify:
```sql
\d grocery_list
```
You should see the new columns `item_image` and `image_mime_type`.

View File

@ -0,0 +1,12 @@
{
"stale_files": [
"add_display_name_column.sql",
"add_image_columns.sql",
"add_modified_on_column.sql",
"add_notes_column.sql",
"create_item_classification_table.sql",
"create_sessions_table.sql",
"multi_household_architecture.sql",
"zz_group_invites_and_join_policies.sql"
]
}

View File

@ -0,0 +1,165 @@
BEGIN;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'group_join_policy') THEN
CREATE TYPE group_join_policy AS ENUM (
'NOT_ACCEPTING',
'AUTO_ACCEPT',
'APPROVAL_REQUIRED'
);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'group_join_request_status') THEN
CREATE TYPE group_join_request_status AS ENUM (
'PENDING',
'APPROVED',
'DENIED',
'CANCELED'
);
END IF;
END
$$;
CREATE TABLE IF NOT EXISTS group_settings (
group_id INTEGER PRIMARY KEY REFERENCES households(id) ON DELETE CASCADE,
join_policy group_join_policy NOT NULL DEFAULT 'NOT_ACCEPTING',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO group_settings (group_id, join_policy)
SELECT h.id, 'NOT_ACCEPTING'::group_join_policy
FROM households h
WHERE NOT EXISTS (
SELECT 1 FROM group_settings gs WHERE gs.group_id = h.id
);
CREATE TABLE IF NOT EXISTS group_join_requests (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status group_join_request_status NOT NULL DEFAULT 'PENDING',
decided_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
decided_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_group_join_requests_pending
ON group_join_requests(group_id, user_id)
WHERE status = 'PENDING';
CREATE INDEX IF NOT EXISTS idx_group_join_requests_group
ON group_join_requests(group_id);
CREATE TABLE IF NOT EXISTS group_invite_links (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
policy group_join_policy NOT NULL DEFAULT 'NOT_ACCEPTING',
single_use BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_group_invite_links_group_id
ON group_invite_links(group_id);
CREATE TABLE IF NOT EXISTS group_audit_log (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE,
actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
actor_role VARCHAR(20),
event_type VARCHAR(100) NOT NULL,
request_id VARCHAR(128) NOT NULL,
ip INET,
user_agent TEXT,
success BOOLEAN NOT NULL DEFAULT TRUE,
error_code VARCHAR(100),
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_group_audit_group_created
ON group_audit_log(group_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_group_audit_request_id
ON group_audit_log(request_id);
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'households'
AND indexdef ILIKE 'CREATE UNIQUE INDEX%'
AND indexdef ILIKE '%(invite_code)%'
) THEN
CREATE UNIQUE INDEX idx_households_invite_code_unique
ON households(invite_code);
END IF;
END
$$;
ALTER TABLE household_members
DROP CONSTRAINT IF EXISTS household_members_role_check;
UPDATE household_members
SET role = 'member'
WHERE role = 'user';
WITH ranked_admins AS (
SELECT
hm.id,
ROW_NUMBER() OVER (
PARTITION BY hm.household_id
ORDER BY hm.joined_at ASC, hm.id ASC
) AS admin_rank
FROM household_members hm
WHERE hm.role = 'admin'
)
UPDATE household_members hm
SET role = CASE
WHEN ra.admin_rank = 1 THEN 'owner'
ELSE 'admin'
END
FROM ranked_admins ra
WHERE hm.id = ra.id;
WITH ownerless_households AS (
SELECT h.id AS household_id
FROM households h
WHERE NOT EXISTS (
SELECT 1
FROM household_members hm
WHERE hm.household_id = h.id
AND hm.role = 'owner'
)
),
first_member AS (
SELECT DISTINCT ON (hm.household_id)
hm.id,
hm.household_id
FROM household_members hm
JOIN ownerless_households oh ON oh.household_id = hm.household_id
ORDER BY hm.household_id, hm.joined_at ASC, hm.id ASC
)
UPDATE household_members hm
SET role = 'owner'
FROM first_member fm
WHERE hm.id = fm.id;
ALTER TABLE household_members
ADD CONSTRAINT household_members_role_check
CHECK (role IN ('owner', 'admin', 'member'));
COMMIT;

32
rebuild-dev.sh Normal file
View File

@ -0,0 +1,32 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.dev.yml"
find_compose_cmd() {
if command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD=(docker-compose)
return
fi
if command -v docker >/dev/null 2>&1; then
COMPOSE_CMD=(docker compose)
return
fi
echo "Docker Compose not found. Install docker-compose or Docker Desktop first."
exit 1
}
main() {
find_compose_cmd
echo "Stopping containers and removing volumes..."
"${COMPOSE_CMD[@]}" -f "$COMPOSE_FILE" down -v
echo "Rebuilding and starting containers..."
"${COMPOSE_CMD[@]}" -f "$COMPOSE_FILE" up --build
}
main "$@"

View File

@ -11,6 +11,48 @@ const migrationsDir = path.resolve(
"db", "db",
"migrations" "migrations"
); );
const staleConfigPath = path.join(migrationsDir, "stale-files.json");
function readStaleConfigFile() {
if (!fs.existsSync(staleConfigPath)) {
return new Set();
}
const raw = fs.readFileSync(staleConfigPath, "utf8");
let parsed;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(`Invalid JSON in ${staleConfigPath}`);
}
const values = Array.isArray(parsed?.stale_files) ? parsed.stale_files : [];
return new Set(
values
.map((value) => String(value || "").trim())
.filter(Boolean)
);
}
function getSkippedMigrations() {
const includeStale = String(process.env.DB_MIGRATE_INCLUDE_STALE || "")
.trim()
.toLowerCase();
const skipFromConfig =
includeStale === "1" || includeStale === "true" || includeStale === "yes"
? new Set()
: readStaleConfigFile();
const raw = process.env.DB_MIGRATE_SKIP_FILES || "";
const skipFromEnv = new Set(
raw
.split(",")
.map((value) => value.trim())
.filter(Boolean)
);
return new Set([...skipFromConfig, ...skipFromEnv]);
}
function ensureDatabaseUrl() { function ensureDatabaseUrl() {
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
@ -35,9 +77,11 @@ function ensureMigrationsDir() {
function getMigrationFiles() { function getMigrationFiles() {
ensureMigrationsDir(); ensureMigrationsDir();
const skipped = getSkippedMigrations();
return fs return fs
.readdirSync(migrationsDir) .readdirSync(migrationsDir)
.filter((file) => file.endsWith(".sql")) .filter((file) => file.endsWith(".sql"))
.filter((file) => !skipped.has(file))
.sort((a, b) => a.localeCompare(b)); .sort((a, b) => a.localeCompare(b));
} }
@ -104,5 +148,6 @@ module.exports = {
ensureSchemaMigrationsTable, ensureSchemaMigrationsTable,
getAppliedMigrations, getAppliedMigrations,
getMigrationFiles, getMigrationFiles,
getSkippedMigrations,
migrationsDir, migrationsDir,
}; };

69
scripts/db-migrate-new.js Normal file
View File

@ -0,0 +1,69 @@
"use strict";
const fs = require("fs");
const path = require("path");
const { migrationsDir } = require("./db-migrate-common");
function sanitizeName(input) {
return String(input || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
function timestampUtc() {
const now = new Date();
const pad = (value) => String(value).padStart(2, "0");
return [
now.getUTCFullYear(),
pad(now.getUTCMonth() + 1),
pad(now.getUTCDate()),
"_",
pad(now.getUTCHours()),
pad(now.getUTCMinutes()),
pad(now.getUTCSeconds()),
].join("");
}
function main() {
const rawName = process.argv.slice(2).join(" ").trim();
if (!rawName || process.argv.includes("--help")) {
console.log("Usage: npm run db:migrate:new -- <migration-name>");
process.exit(rawName ? 0 : 1);
}
const name = sanitizeName(rawName);
if (!name) {
throw new Error("Migration name must contain letters or numbers.");
}
if (!fs.existsSync(migrationsDir)) {
throw new Error(`Migrations directory not found: ${migrationsDir}`);
}
const filename = `${timestampUtc()}_${name}.sql`;
const fullPath = path.join(migrationsDir, filename);
if (fs.existsSync(fullPath)) {
throw new Error(`Migration already exists: ${filename}`);
}
const template = [
"BEGIN;",
"",
"-- Add schema changes here.",
"",
"COMMIT;",
"",
].join("\n");
fs.writeFileSync(fullPath, template, "utf8");
console.log(`Created migration: ${path.relative(process.cwd(), fullPath)}`);
}
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}

View File

@ -15,6 +15,14 @@ function main() {
process.exit(0); process.exit(0);
} }
const migrateDisabled = String(process.env.DB_MIGRATE_DISABLE || "")
.trim()
.toLowerCase();
if (migrateDisabled === "1" || migrateDisabled === "true" || migrateDisabled === "yes") {
console.log("DB migrations are disabled by DB_MIGRATE_DISABLE. Skipping.");
return;
}
const databaseUrl = ensureDatabaseUrl(); const databaseUrl = ensureDatabaseUrl();
ensurePsql(); ensurePsql();
ensureSchemaMigrationsTable(databaseUrl); ensureSchemaMigrationsTable(databaseUrl);

View File

@ -0,0 +1,187 @@
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const repoRoot = path.resolve(__dirname, "..");
const canonicalDir = path.resolve(repoRoot, "packages", "db", "migrations");
const legacyDir = path.resolve(repoRoot, "backend", "migrations");
const defaultReportPath = path.resolve(legacyDir, "stale-sql-report.json");
function parseArgs(argv) {
const args = new Set(argv);
return {
write: args.has("--write"),
failOnStale: args.has("--fail-on-stale"),
help: args.has("--help"),
};
}
function ensureDirectoryExists(dirPath, label) {
if (!fs.existsSync(dirPath)) {
throw new Error(`${label} directory not found: ${dirPath}`);
}
}
function sha256File(filePath) {
const hash = crypto.createHash("sha256");
hash.update(fs.readFileSync(filePath));
return hash.digest("hex");
}
function listFiles(dirPath) {
return fs
.readdirSync(dirPath)
.filter((name) => fs.statSync(path.join(dirPath, name)).isFile())
.sort((a, b) => a.localeCompare(b));
}
function listSqlFiles(dirPath) {
return listFiles(dirPath).filter((name) => name.toLowerCase().endsWith(".sql"));
}
function mapByNameWithHash(dirPath, names) {
const map = new Map();
for (const name of names) {
map.set(name, {
name,
path: path.join(dirPath, name),
sha256: sha256File(path.join(dirPath, name)),
});
}
return map;
}
function buildReport() {
ensureDirectoryExists(canonicalDir, "Canonical migrations");
ensureDirectoryExists(legacyDir, "Legacy migrations");
const canonicalSql = listSqlFiles(canonicalDir);
const legacySql = listSqlFiles(legacyDir);
const legacyNonSql = listFiles(legacyDir).filter(
(name) => !name.toLowerCase().endsWith(".sql")
);
const canonicalMap = mapByNameWithHash(canonicalDir, canonicalSql);
const legacyMap = mapByNameWithHash(legacyDir, legacySql);
const staleFiles = [];
for (const legacyName of legacySql) {
const legacyFile = legacyMap.get(legacyName);
const canonicalFile = canonicalMap.get(legacyName);
if (!canonicalFile) {
staleFiles.push({
filename: legacyName,
status: "STALE_ONLY_IN_BACKEND",
backend_sha256: legacyFile.sha256,
});
continue;
}
if (legacyFile.sha256 === canonicalFile.sha256) {
staleFiles.push({
filename: legacyName,
status: "STALE_DUPLICATE_OF_CANONICAL",
backend_sha256: legacyFile.sha256,
canonical_sha256: canonicalFile.sha256,
});
continue;
}
staleFiles.push({
filename: legacyName,
status: "STALE_DIVERGED_FROM_CANONICAL",
backend_sha256: legacyFile.sha256,
canonical_sha256: canonicalFile.sha256,
});
}
const canonicalOnly = canonicalSql
.filter((name) => !legacyMap.has(name))
.map((name) => ({
filename: name,
status: "CANONICAL_ONLY",
canonical_sha256: canonicalMap.get(name).sha256,
}));
return {
generated_at: new Date().toISOString(),
canonical_dir: path.relative(repoRoot, canonicalDir),
legacy_dir: path.relative(repoRoot, legacyDir),
stale_sql_files: staleFiles,
canonical_only_sql_files: canonicalOnly,
legacy_non_sql_files: legacyNonSql,
summary: {
stale_total: staleFiles.length,
stale_only_in_backend_total: staleFiles.filter(
(f) => f.status === "STALE_ONLY_IN_BACKEND"
).length,
stale_duplicate_total: staleFiles.filter(
(f) => f.status === "STALE_DUPLICATE_OF_CANONICAL"
).length,
stale_diverged_total: staleFiles.filter(
(f) => f.status === "STALE_DIVERGED_FROM_CANONICAL"
).length,
canonical_only_total: canonicalOnly.length,
},
};
}
function printReport(report) {
console.log("Stale SQL Tracker");
console.log(`- Canonical: ${report.canonical_dir}`);
console.log(`- Legacy: ${report.legacy_dir}`);
console.log(`- Generated: ${report.generated_at}`);
console.log("");
console.log(`Stale SQL files in legacy dir: ${report.summary.stale_total}`);
for (const stale of report.stale_sql_files) {
console.log(` - ${stale.filename} :: ${stale.status}`);
}
console.log("");
console.log(`Canonical-only SQL files: ${report.summary.canonical_only_total}`);
for (const canonicalOnly of report.canonical_only_sql_files) {
console.log(` - ${canonicalOnly.filename}`);
}
console.log("");
console.log(`Legacy non-SQL files: ${report.legacy_non_sql_files.length}`);
for (const nonSql of report.legacy_non_sql_files) {
console.log(` - ${nonSql}`);
}
}
function writeReport(report) {
fs.writeFileSync(defaultReportPath, JSON.stringify(report, null, 2) + "\n", "utf8");
console.log("");
console.log(`Wrote stale SQL report: ${path.relative(repoRoot, defaultReportPath)}`);
}
function main() {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
console.log("Usage: node scripts/db-stale-sql-tracker.js [--write] [--fail-on-stale]");
process.exit(0);
}
const report = buildReport();
printReport(report);
if (options.write) {
writeReport(report);
}
if (options.failOnStale && report.summary.stale_total > 0) {
process.exit(1);
}
}
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}