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
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:
parent
ee94853084
commit
77ae5be445
4
.gitignore
vendored
4
.gitignore
vendored
@ -7,6 +7,10 @@ node_modules/
|
||||
# Build output (if using a bundler or React later)
|
||||
dist/
|
||||
build/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.npm-cache/
|
||||
.playwright-browsers/
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
|
||||
1
.vscode-extensions/extensions.json
Normal file
1
.vscode-extensions/extensions.json
Normal file
@ -0,0 +1 @@
|
||||
[]
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
12
.vscode-user/logs/20260219T013031/cli.log
Normal file
12
.vscode-user/logs/20260219T013031/cli.log
Normal 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
|
||||
14
.vscode-user/logs/20260219T013038/cli.log
Normal file
14
.vscode-user/logs/20260219T013038/cli.log
Normal 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
1
.vscode-user/machineid
Normal file
@ -0,0 +1 @@
|
||||
490a70bb-d2b1-490e-9046-37c8a08b0270
|
||||
@ -21,6 +21,8 @@
|
||||
- Receipt images stored in `receipts` (`bytea`).
|
||||
- Entries list endpoints must NEVER return receipt bytes.
|
||||
- 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)
|
||||
1) API routes: `app/api/**/route.ts`
|
||||
|
||||
@ -154,7 +154,8 @@ For `app/api/**/[param]/route.ts`:
|
||||
- Mouse: hover affordance on interactive rows/cards.
|
||||
- Tap targets remain >= 40px on mobile.
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
@ -2,6 +2,8 @@ FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache postgresql-client
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
|
||||
@ -63,6 +63,9 @@ app.use("/households", householdsRoutes);
|
||||
const storesRoutes = require("./routes/stores.routes");
|
||||
app.use("/stores", storesRoutes);
|
||||
|
||||
const groupInvitesRoutes = require("./routes/group-invites.routes");
|
||||
app.use("/api", groupInvitesRoutes);
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
|
||||
216
backend/controllers/group-invites.controller.js
Normal file
216
backend/controllers/group-invites.controller.js
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -165,8 +165,8 @@ exports.updateMemberRole = async (req, res) => {
|
||||
const { userId } = req.params;
|
||||
const { role } = req.body;
|
||||
|
||||
if (!role || !['admin', 'user'].includes(role)) {
|
||||
return sendError(res, 400, "Invalid role. Must be 'admin' or 'user'");
|
||||
if (!role || !['admin', 'member'].includes(role)) {
|
||||
return sendError(res, 400, "Invalid role. Must be 'admin' or 'member'");
|
||||
}
|
||||
|
||||
// Can't change own role
|
||||
@ -174,6 +174,14 @@ exports.updateMemberRole = async (req, res) => {
|
||||
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(
|
||||
req.params.householdId,
|
||||
userId,
|
||||
@ -197,8 +205,13 @@ exports.removeMember = async (req, res) => {
|
||||
const targetUserId = parseInt(userId);
|
||||
|
||||
// Allow users to remove themselves, or admins to remove others
|
||||
if (targetUserId !== req.user.id && req.household.role !== 'admin') {
|
||||
return sendError(res, 403, "Only admins can remove other members");
|
||||
if (targetUserId !== req.user.id && !["owner", "admin"].includes(req.household.role)) {
|
||||
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);
|
||||
|
||||
@ -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() !== "") {
|
||||
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) {
|
||||
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
||||
|
||||
@ -54,8 +54,8 @@ exports.requireHouseholdRole = (...allowedRoles) => {
|
||||
};
|
||||
};
|
||||
|
||||
// Middleware to require admin role in household
|
||||
exports.requireHouseholdAdmin = exports.requireHouseholdRole('admin');
|
||||
// Middleware to require admin/owner role in household
|
||||
exports.requireHouseholdAdmin = exports.requireHouseholdRole('owner', 'admin');
|
||||
|
||||
// Middleware to check store access (household must have store)
|
||||
exports.storeAccess = async (req, res, next) => {
|
||||
|
||||
47
backend/middleware/optional-auth.js
Normal file
47
backend/middleware/optional-auth.js
Normal 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;
|
||||
@ -18,7 +18,7 @@ function getClientIp(req) {
|
||||
return req.ip || req.socket?.remoteAddress || "unknown";
|
||||
}
|
||||
|
||||
function createRateLimit({ keyPrefix, windowMs, max, message }) {
|
||||
function createRateLimit({ keyPrefix, windowMs, max, message, keyFn }) {
|
||||
return (req, res, next) => {
|
||||
const now = Date.now();
|
||||
|
||||
@ -26,7 +26,8 @@ function createRateLimit({ keyPrefix, windowMs, max, message }) {
|
||||
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 bucket =
|
||||
!existing || existing.resetAt <= now
|
||||
|
||||
65
backend/migrations/stale-sql-report.json
Normal file
65
backend/migrations/stale-sql-report.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -54,7 +54,7 @@ exports.createHousehold = async (name, createdBy) => {
|
||||
// Add creator as admin
|
||||
await pool.query(
|
||||
`INSERT INTO household_members (household_id, user_id, role)
|
||||
VALUES ($1, $2, 'admin')`,
|
||||
VALUES ($1, $2, 'owner')`,
|
||||
[result.rows[0].id, createdBy]
|
||||
);
|
||||
|
||||
@ -118,7 +118,7 @@ exports.joinHousehold = async (inviteCode, userId) => {
|
||||
// Add as user role
|
||||
await pool.query(
|
||||
`INSERT INTO household_members (household_id, user_id, role)
|
||||
VALUES ($1, $2, 'user')`,
|
||||
VALUES ($1, $2, 'member')`,
|
||||
[household.id, userId]
|
||||
);
|
||||
|
||||
@ -140,8 +140,9 @@ exports.getHouseholdMembers = async (householdId) => {
|
||||
WHERE hm.household_id = $1
|
||||
ORDER BY
|
||||
CASE hm.role
|
||||
WHEN 'admin' THEN 1
|
||||
WHEN 'user' THEN 2
|
||||
WHEN 'owner' THEN 1
|
||||
WHEN 'admin' THEN 2
|
||||
WHEN 'member' THEN 3
|
||||
END,
|
||||
hm.joined_at ASC`,
|
||||
[householdId]
|
||||
|
||||
72
backend/routes/group-invites.routes.js
Normal file
72
backend/routes/group-invites.routes.js
Normal 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;
|
||||
557
backend/services/group-invites.service.js
Normal file
557
backend/services/group-invites.service.js
Normal 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,
|
||||
};
|
||||
74
backend/tests/group-invites.routes.test.js
Normal file
74
backend/tests/group-invites.routes.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
189
backend/tests/group-invites.service.test.js
Normal file
189
backend/tests/group-invites.service.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -50,6 +50,40 @@ describe("lists.controller.v2 addItem", () => {
|
||||
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 () => {
|
||||
const req = {
|
||||
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 () => {
|
||||
householdModel.isHouseholdMember.mockResolvedValue(false);
|
||||
|
||||
|
||||
6
debug.log
Normal file
6
debug.log
Normal 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)
|
||||
@ -1,8 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Quick script to rebuild Docker Compose dev environment
|
||||
set -euo pipefail
|
||||
|
||||
echo "Stopping containers and removing volumes..."
|
||||
docker-compose -f docker-compose.dev.yml down -v
|
||||
|
||||
echo "Rebuilding and starting containers..."
|
||||
docker-compose -f docker-compose.dev.yml up --build
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
exec "$SCRIPT_DIR/rebuild-dev.sh" "$@"
|
||||
|
||||
@ -16,10 +16,19 @@ This project uses an external on-prem Postgres database. Migration files are can
|
||||
- `npm run db:migrate:status`
|
||||
- Fail if pending migrations exist:
|
||||
- `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
|
||||
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:
|
||||
- `add_display_name_column.sql`
|
||||
- `add_image_columns.sql`
|
||||
@ -37,9 +46,11 @@ Applied migrations are recorded in:
|
||||
## Expected operator flow
|
||||
1. Check 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`
|
||||
3. Verify clean state:
|
||||
4. Verify clean state:
|
||||
- `npm run db:migrate:verify`
|
||||
|
||||
## Troubleshooting
|
||||
@ -49,3 +60,8 @@ Applied migrations are recorded in:
|
||||
- Install PostgreSQL client tools and retry.
|
||||
- SQL failure:
|
||||
- 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.
|
||||
|
||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@ -15,6 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
@ -981,6 +982,21 @@
|
||||
"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": {
|
||||
"version": "1.0.0-beta.47",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
|
||||
@ -2915,6 +2931,50 @@
|
||||
"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": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
|
||||
@ -7,7 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"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": {
|
||||
"axios": "^1.13.2",
|
||||
@ -16,6 +19,7 @@
|
||||
"react-router-dom": "^7.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "^19.2.5",
|
||||
|
||||
29
frontend/playwright.config.ts
Normal file
29
frontend/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@ -1,8 +1,10 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { ROLES } from "./constants/roles";
|
||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||
import { ActionToastProvider } from "./context/ActionToastContext.jsx";
|
||||
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
|
||||
import { UploadQueueProvider } from "./context/UploadQueueContext.jsx";
|
||||
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||
import { StoreProvider } from "./context/StoreContext.jsx";
|
||||
|
||||
@ -12,26 +14,28 @@ import Login from "./pages/Login.jsx";
|
||||
import Manage from "./pages/Manage.jsx";
|
||||
import Register from "./pages/Register.jsx";
|
||||
import Settings from "./pages/Settings.jsx";
|
||||
import InviteLink from "./pages/InviteLink.jsx";
|
||||
|
||||
import AppLayout from "./components/layout/AppLayout.jsx";
|
||||
import UploadToaster from "./components/common/UploadToaster.jsx";
|
||||
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
||||
|
||||
import RoleGuard from "./utils/RoleGuard.jsx";
|
||||
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<AuthProvider>
|
||||
<HouseholdProvider>
|
||||
<StoreProvider>
|
||||
<UploadQueueProvider>
|
||||
<ActionToastProvider>
|
||||
<SettingsProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
|
||||
{/* Public route */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/invite/:token" element={<InviteLink />} />
|
||||
|
||||
{/* Private routes with layout */}
|
||||
<Route
|
||||
@ -54,10 +58,12 @@ function App() {
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
</Routes>
|
||||
<UploadToaster />
|
||||
</BrowserRouter>
|
||||
</SettingsProvider>
|
||||
</ActionToastProvider>
|
||||
</UploadQueueProvider>
|
||||
</StoreProvider>
|
||||
</HouseholdProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@ -56,3 +56,38 @@ export const updateMemberRole = (householdId, userId, role) =>
|
||||
*/
|
||||
export const removeMember = (householdId, 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)}`);
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import "../../styles/components/admin/StoreManagement.css";
|
||||
|
||||
export default function StoreManagement() {
|
||||
const toast = useActionToast();
|
||||
const [stores, setStores] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingStore, setEditingStore] = useState(null);
|
||||
@ -24,7 +27,8 @@ export default function StoreManagement() {
|
||||
setStores(response.data);
|
||||
} catch (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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -38,12 +42,14 @@ export default function StoreManagement() {
|
||||
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
||||
await createStore(formData.name, zonesJson);
|
||||
await loadStores();
|
||||
toast.success("Created store", `Created store ${formData.name.trim()}`);
|
||||
setShowCreateForm(false);
|
||||
setFormData({ name: "", zones: [] });
|
||||
setNewZone("");
|
||||
} catch (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;
|
||||
await updateStore(editingStore.id, formData.name, zonesJson);
|
||||
await loadStores();
|
||||
toast.success("Updated store", `Updated store ${formData.name.trim()}`);
|
||||
setEditingStore(null);
|
||||
setFormData({ name: "", zones: [] });
|
||||
setNewZone("");
|
||||
} catch (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 {
|
||||
await deleteStore(storeId);
|
||||
await loadStores();
|
||||
toast.success("Deleted store", `Deleted store ${storeName}`);
|
||||
} catch (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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
92
frontend/src/components/common/UploadToaster.jsx
Normal file
92
frontend/src/components/common/UploadToaster.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -37,7 +37,14 @@ export default function AddItemForm({
|
||||
e.preventDefault();
|
||||
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);
|
||||
setItemName("");
|
||||
setQuantity(1);
|
||||
@ -124,7 +131,7 @@ export default function AddItemForm({
|
||||
value={assignmentMode}
|
||||
ariaLabel="Item assignment mode"
|
||||
className="tbg-group add-item-form-assignee-toggle"
|
||||
sizeClassName="tbg-size-xs"
|
||||
sizeClassName="tbg-size-xxs"
|
||||
options={[
|
||||
{ value: "me", label: "Me" },
|
||||
{ value: "others", label: "Others", disabled: otherMembers.length === 0 }
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
import "../../styles/components/Navbar.css";
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { logoutRequest } from "../../api/auth";
|
||||
import { AuthContext } from "../../context/AuthContext";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import HouseholdSwitcher from "../household/HouseholdSwitcher";
|
||||
|
||||
export default function Navbar() {
|
||||
const navigate = useNavigate();
|
||||
const { role, logout, username } = useContext(AuthContext);
|
||||
const toast = useActionToast();
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
|
||||
const closeMenus = () => {
|
||||
@ -15,14 +19,23 @@ export default function Navbar() {
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
let loggedOutRemotely = true;
|
||||
let fallbackReason = "";
|
||||
try {
|
||||
await logoutRequest();
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
// Clear local auth state even if server logout fails.
|
||||
loggedOutRemotely = false;
|
||||
fallbackReason = getApiErrorMessage(error, "Unable to end server session");
|
||||
} finally {
|
||||
logout();
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,16 +1,39 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { createHousehold, joinHousehold } from "../../api/households";
|
||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import "../../styles/components/manage/CreateJoinHousehold.css";
|
||||
|
||||
export default function CreateJoinHousehold({ onClose }) {
|
||||
const navigate = useNavigate();
|
||||
const toast = useActionToast();
|
||||
const { refreshHouseholds } = useContext(HouseholdContext);
|
||||
const [mode, setMode] = useState("create"); // "create" or "join"
|
||||
const [mode, setMode] = useState("create");
|
||||
const [householdName, setHouseholdName] = useState("");
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!householdName.trim()) return;
|
||||
@ -21,10 +44,13 @@ export default function CreateJoinHousehold({ onClose }) {
|
||||
try {
|
||||
await createHousehold(householdName);
|
||||
await refreshHouseholds();
|
||||
toast.success("Created household", `Created household ${householdName.trim()}`);
|
||||
onClose();
|
||||
} catch (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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -38,12 +64,23 @@ export default function CreateJoinHousehold({ onClose }) {
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const inviteToken = extractInviteToken(inviteCode);
|
||||
if (inviteToken) {
|
||||
toast.info("Invite link detected", "Opening invite details");
|
||||
onClose();
|
||||
navigate(`/invite/${inviteToken}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await joinHousehold(inviteCode);
|
||||
await refreshHouseholds();
|
||||
toast.success("Joined household", "Joined household successfully");
|
||||
onClose();
|
||||
} catch (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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -54,7 +91,7 @@ export default function CreateJoinHousehold({ onClose }) {
|
||||
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Household</h2>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
<button className="close-btn" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="mode-tabs">
|
||||
@ -100,18 +137,18 @@ export default function CreateJoinHousehold({ onClose }) {
|
||||
) : (
|
||||
<form onSubmit={handleJoin} className="household-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="inviteCode">Invite Code</label>
|
||||
<label htmlFor="inviteCode">Invite Code or Link</label>
|
||||
<input
|
||||
id="inviteCode"
|
||||
type="text"
|
||||
value={inviteCode}
|
||||
onChange={(e) => setInviteCode(e.target.value)}
|
||||
placeholder="Enter invite code"
|
||||
placeholder="Invite code or /invite URL"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
<p className="form-hint">
|
||||
Ask the household admin for the invite code
|
||||
Paste a raw invite code or full invite link URL
|
||||
</p>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
|
||||
@ -1,30 +1,59 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
createGroupInviteLink,
|
||||
deleteGroupInviteLink,
|
||||
deleteHousehold,
|
||||
getGroupInviteLinks,
|
||||
getGroupJoinPolicy,
|
||||
getHouseholdMembers,
|
||||
refreshInviteCode,
|
||||
removeMember,
|
||||
revokeGroupInviteLink,
|
||||
reviveGroupInviteLink,
|
||||
setGroupJoinPolicy,
|
||||
updateHousehold,
|
||||
updateMemberRole
|
||||
} from "../../api/households";
|
||||
import { ToggleButtonGroup } from "../common";
|
||||
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
|
||||
import { AuthContext } from "../../context/AuthContext";
|
||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
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() {
|
||||
const { userId } = useContext(AuthContext);
|
||||
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
|
||||
const toast = useActionToast();
|
||||
const [members, setMembers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
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(() => {
|
||||
loadMembers();
|
||||
}, [activeHousehold?.id]);
|
||||
if (isManager) {
|
||||
loadJoinAndInvites();
|
||||
}
|
||||
}, [activeHousehold?.id, isManager]);
|
||||
|
||||
const loadMembers = async () => {
|
||||
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 () => {
|
||||
if (!newName.trim() || newName === activeHousehold.name) {
|
||||
setEditingName(false);
|
||||
@ -46,15 +216,13 @@ export default function ManageHousehold() {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Updating household:', activeHousehold.id, 'with name:', newName);
|
||||
const response = await updateHousehold(activeHousehold.id, newName);
|
||||
console.log('Update response:', response);
|
||||
await updateHousehold(activeHousehold.id, newName);
|
||||
await refreshHouseholds();
|
||||
toast.success("Updated household name", `Updated household name to ${newName.trim()}`);
|
||||
setEditingName(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update household name:", error);
|
||||
console.error("Error response:", error.response?.data);
|
||||
alert(`Failed to update household name: ${error.response?.data?.error || error.message}`);
|
||||
const message = getApiErrorMessage(error, "Failed to update household name");
|
||||
toast.error("Update household name failed", `Update household name failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
@ -62,46 +230,39 @@ export default function ManageHousehold() {
|
||||
if (!confirm("Generate a new invite code? The old code will no longer work.")) return;
|
||||
|
||||
try {
|
||||
const response = await refreshInviteCode(activeHousehold.id);
|
||||
await refreshInviteCode(activeHousehold.id);
|
||||
await refreshHouseholds();
|
||||
const refreshedInviteCode = response.data?.household?.invite_code;
|
||||
if (refreshedInviteCode) {
|
||||
alert(`New invite code: ${refreshedInviteCode}`);
|
||||
} else {
|
||||
alert("Invite code refreshed successfully");
|
||||
}
|
||||
toast.success("Generated new invite code", "Generated a new invite code");
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to refresh invite code:",
|
||||
error?.response?.data?.error?.message ||
|
||||
error?.response?.data?.message ||
|
||||
error?.message
|
||||
);
|
||||
alert("Failed to refresh invite code");
|
||||
const message = getApiErrorMessage(error, "Failed to refresh invite code");
|
||||
toast.error("Refresh invite code failed", `Refresh invite code failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (userId, currentRole) => {
|
||||
const handleUpdateRole = async (memberId, currentRole, memberName) => {
|
||||
if (currentRole === "owner") return;
|
||||
const newRole = currentRole === "admin" ? "member" : "admin";
|
||||
|
||||
try {
|
||||
await updateMemberRole(activeHousehold.id, userId, newRole);
|
||||
await updateMemberRole(activeHousehold.id, memberId, newRole);
|
||||
await loadMembers();
|
||||
toast.success("Updated member role", `Updated role for ${memberName} to ${newRole}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to update role:", error);
|
||||
alert("Failed to update member role");
|
||||
const message = getApiErrorMessage(error, "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;
|
||||
|
||||
try {
|
||||
await removeMember(activeHousehold.id, userId);
|
||||
await removeMember(activeHousehold.id, memberId);
|
||||
await loadMembers();
|
||||
toast.success("Removed member", `Removed member ${username}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to remove member:", error);
|
||||
alert("Failed to remove member");
|
||||
const message = getApiErrorMessage(error, "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;
|
||||
|
||||
try {
|
||||
const householdName = activeHousehold.name;
|
||||
await deleteHousehold(activeHousehold.id);
|
||||
await refreshHouseholds();
|
||||
toast.success("Deleted household", `Deleted household ${householdName}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete household:", error);
|
||||
alert("Failed to delete household");
|
||||
const message = getApiErrorMessage(error, "Failed to delete household");
|
||||
toast.error("Delete household failed", `Delete household failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const copyInviteCode = () => {
|
||||
navigator.clipboard.writeText(activeHousehold.invite_code);
|
||||
alert("Invite code copied to clipboard!");
|
||||
const handleLeaveHousehold = async () => {
|
||||
if (!activeHousehold?.id) return;
|
||||
|
||||
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 (
|
||||
<div className="manage-household">
|
||||
{/* Household Name Section */}
|
||||
<section key="household-name" className="manage-section">
|
||||
<h2>Household Name</h2>
|
||||
{editingName ? (
|
||||
@ -143,7 +328,7 @@ export default function ManageHousehold() {
|
||||
) : (
|
||||
<div className="name-display">
|
||||
<h3>{activeHousehold.name}</h3>
|
||||
{isAdmin && (
|
||||
{isManager && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewName(activeHousehold.name);
|
||||
@ -158,12 +343,11 @@ export default function ManageHousehold() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Invite Code Section */}
|
||||
{isAdmin && (
|
||||
{isManager && (
|
||||
<section key="invite-code" className="manage-section">
|
||||
<h2>Invite Code</h2>
|
||||
<h2>Legacy Invite Code</h2>
|
||||
<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>
|
||||
<div className="invite-actions">
|
||||
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
|
||||
@ -182,7 +366,85 @@ export default function ManageHousehold() {
|
||||
</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">
|
||||
<h2>Members ({members.length})</h2>
|
||||
{loading ? (
|
||||
@ -192,22 +454,15 @@ export default function ManageHousehold() {
|
||||
{members.map((member) => (
|
||||
<div key={member.id} className="member-card">
|
||||
<div className="member-info">
|
||||
<span className="member-role">
|
||||
{member.role}
|
||||
</span>
|
||||
<span className="member-role">{member.role}</span>
|
||||
<span className="member-name">
|
||||
{`
|
||||
${member.username}
|
||||
[${member.id}]
|
||||
${(member.id === parseInt(userId) ? " (You)" : "")}
|
||||
`}
|
||||
{member.username} [{member.id}] {member.id === parseInt(userId, 10) ? "(You)" : ""}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
{isAdmin && member.id !== parseInt(userId) && (
|
||||
{isManager && member.id !== parseInt(userId, 10) && member.role !== "owner" && (
|
||||
<div className="member-actions">
|
||||
<button
|
||||
onClick={() => handleUpdateRole(member.id, member.role)}
|
||||
onClick={() => handleUpdateRole(member.id, member.role, member.username)}
|
||||
className="btn-secondary btn-small"
|
||||
>
|
||||
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
||||
@ -226,18 +481,34 @@ export default function ManageHousehold() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Danger Zone */}
|
||||
{isAdmin && (
|
||||
{(isManager || isMemberOnly) && (
|
||||
<section key="danger-zone" className="manage-section danger-zone">
|
||||
<h2>Danger Zone</h2>
|
||||
<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>
|
||||
{isMemberOnly ? (
|
||||
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
|
||||
Leave Household
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleDeleteHousehold} className="btn-danger">
|
||||
Delete Household
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,16 +7,19 @@ import {
|
||||
} from "../../api/stores";
|
||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||
import { StoreContext } from "../../context/StoreContext";
|
||||
import useActionToast from "../../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||
import "../../styles/components/manage/ManageStores.css";
|
||||
|
||||
export default function ManageStores() {
|
||||
const { activeHousehold } = useContext(HouseholdContext);
|
||||
const { stores: householdStores, refreshStores } = useContext(StoreContext);
|
||||
const toast = useActionToast();
|
||||
const [allStores, setAllStores] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddStore, setShowAddStore] = useState(false);
|
||||
|
||||
const isAdmin = activeHousehold?.role === "admin";
|
||||
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
||||
|
||||
useEffect(() => {
|
||||
loadAllStores();
|
||||
@ -35,14 +38,17 @@ export default function ManageStores() {
|
||||
};
|
||||
|
||||
const handleAddStore = async (storeId) => {
|
||||
const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
|
||||
try {
|
||||
console.log("Adding store with ID:", storeId);
|
||||
await addStoreToHousehold(activeHousehold.id, storeId, false);
|
||||
await refreshStores();
|
||||
toast.success("Added store", `Added store ${storeName}`);
|
||||
setShowAddStore(false);
|
||||
} catch (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 {
|
||||
await removeStoreFromHousehold(activeHousehold.id, storeId);
|
||||
await refreshStores();
|
||||
toast.success("Removed store", `Removed store ${storeName}`);
|
||||
} catch (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 storeName =
|
||||
householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
|
||||
try {
|
||||
await setDefaultStore(activeHousehold.id, storeId);
|
||||
await refreshStores();
|
||||
toast.success("Updated default store", `Default store set to ${storeName}`);
|
||||
} catch (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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
191
frontend/src/components/modals/ConfirmSlideModal.jsx
Normal file
191
frontend/src/components/modals/ConfirmSlideModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,9 +1,12 @@
|
||||
import { useEffect, useState } from "react";
|
||||
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 AddImageModal from "./AddImageModal";
|
||||
|
||||
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) {
|
||||
const toast = useActionToast();
|
||||
const [itemName, setItemName] = useState(item.item_name || "");
|
||||
const [quantity, setQuantity] = useState(item.quantity || 1);
|
||||
const [itemType, setItemType] = useState("");
|
||||
@ -54,7 +57,8 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
||||
await onSave(item.id, itemName, quantity, classification);
|
||||
} catch (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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -63,11 +67,12 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
||||
const handleImageUpload = async (imageFile) => {
|
||||
if (onImageUpdate) {
|
||||
try {
|
||||
await onImageUpdate(item.id, itemName, quantity, imageFile);
|
||||
await onImageUpdate(item.id, itemName, quantity, imageFile, "edit_modal");
|
||||
setShowImageModal(false);
|
||||
} catch (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}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
105
frontend/src/context/ActionToastContext.jsx
Normal file
105
frontend/src/context/ActionToastContext.jsx
Normal 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>;
|
||||
}
|
||||
406
frontend/src/context/UploadQueueContext.jsx
Normal file
406
frontend/src/context/UploadQueueContext.jsx
Normal 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>;
|
||||
}
|
||||
10
frontend/src/hooks/useActionToast.js
Normal file
10
frontend/src/hooks/useActionToast.js
Normal 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;
|
||||
}
|
||||
6
frontend/src/hooks/useUploadQueue.js
Normal file
6
frontend/src/hooks/useUploadQueue.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { UploadQueueContext } from "../context/UploadQueueContext";
|
||||
|
||||
export default function useUploadQueue() {
|
||||
return useContext(UploadQueueContext);
|
||||
}
|
||||
8
frontend/src/lib/getApiErrorMessage.js
Normal file
8
frontend/src/lib/getApiErrorMessage.js
Normal 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
|
||||
);
|
||||
}
|
||||
58
frontend/src/lib/uploadQueueStorage.js
Normal file
58
frontend/src/lib/uploadQueueStorage.js
Normal 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));
|
||||
}
|
||||
@ -2,17 +2,24 @@ import { useEffect, useState } from "react";
|
||||
import { getAllUsers, updateRole } from "../api/users";
|
||||
import StoreManagement from "../components/admin/StoreManagement";
|
||||
import UserRoleCard from "../components/common/UserRoleCard";
|
||||
import useActionToast from "../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import "../styles/UserRoleCard.css";
|
||||
import "../styles/pages/AdminPanel.css";
|
||||
|
||||
export default function AdminPanel() {
|
||||
const toast = useActionToast();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [activeTab, setActiveTab] = useState("users");
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const allUsers = await getAllUsers();
|
||||
console.log("Users found:", users);
|
||||
setUsers(allUsers.data);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to load users");
|
||||
toast.error("Load users failed", `Load users failed: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@ -20,10 +27,22 @@ export default function AdminPanel() {
|
||||
}, []);
|
||||
|
||||
const changeRole = async (id, role) => {
|
||||
const selectedUser = users.find((user) => user.id === id);
|
||||
const username = selectedUser?.username || `user #${id}`;
|
||||
|
||||
try {
|
||||
const updated = await updateRole(id, role);
|
||||
if (updated.status !== 200) return;
|
||||
loadUsers();
|
||||
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 (
|
||||
<div className="admin-panel-body">
|
||||
@ -62,5 +81,5 @@ export default function AdminPanel() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -26,7 +26,9 @@ import { HouseholdContext } from "../context/HouseholdContext";
|
||||
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
|
||||
import { SettingsContext } from "../context/SettingsContext";
|
||||
import { StoreContext } from "../context/StoreContext";
|
||||
import useActionToast from "../hooks/useActionToast";
|
||||
import useUploadQueue from "../hooks/useUploadQueue";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import "../styles/pages/GroceryList.css";
|
||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||
|
||||
@ -37,6 +39,7 @@ export default function GroceryList() {
|
||||
const { activeHousehold } = useContext(HouseholdContext);
|
||||
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
|
||||
const { settings } = useContext(SettingsContext);
|
||||
const toast = useActionToast();
|
||||
const { enqueueImageUpload } = useUploadQueue();
|
||||
const navigate = useNavigate();
|
||||
|
||||
@ -255,6 +258,7 @@ export default function GroceryList() {
|
||||
|
||||
// === Item Addition Handlers ===
|
||||
const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => {
|
||||
try {
|
||||
const normalizedItemName = itemName.trim().toLowerCase();
|
||||
if (!normalizedItemName) return;
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
@ -289,6 +293,9 @@ export default function GroceryList() {
|
||||
skipLookup: shouldSkipLookup,
|
||||
addedForUserId
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to process add item flow:", error);
|
||||
}
|
||||
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
|
||||
|
||||
|
||||
@ -325,6 +332,7 @@ export default function GroceryList() {
|
||||
});
|
||||
setShowConfirmAddExisting(true);
|
||||
} else if (existingItem) {
|
||||
try {
|
||||
await addItem(
|
||||
activeHousehold.id,
|
||||
activeStore.id,
|
||||
@ -336,15 +344,21 @@ export default function GroceryList() {
|
||||
);
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
toast.success("Added item", `Added item ${itemName}`);
|
||||
|
||||
// Reload lists to reflect the changes
|
||||
await loadItems();
|
||||
await loadRecentlyBought();
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to add item");
|
||||
toast.error("Add item failed", `Add item failed: ${message}`);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
setPendingItem({ itemName, quantity, addedForUserId });
|
||||
setShowAddDetailsModal(true);
|
||||
}
|
||||
}, [activeHousehold?.id, activeStore?.id, loadItems]);
|
||||
}, [activeHousehold?.id, activeStore?.id, loadItems, loadRecentlyBought, toast]);
|
||||
|
||||
|
||||
// === Similar Item Modal Handlers ===
|
||||
@ -407,11 +421,14 @@ export default function GroceryList() {
|
||||
|
||||
setSuggestions([]);
|
||||
setButtonText("Add Item");
|
||||
toast.success("Updated item quantity", `Updated item ${itemName}`);
|
||||
} catch (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();
|
||||
}
|
||||
}, []);
|
||||
}, [activeHousehold?.id, activeStore?.id, confirmAddExistingData, loadItems, toast]);
|
||||
|
||||
|
||||
// === Add Details Modal Handlers ===
|
||||
@ -434,6 +451,7 @@ export default function GroceryList() {
|
||||
if (classification) {
|
||||
// Apply classification if provided
|
||||
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
|
||||
@ -448,6 +466,7 @@ export default function GroceryList() {
|
||||
// Add to state
|
||||
if (newItem) {
|
||||
setItems(prevItems => [...prevItems, newItem]);
|
||||
toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`);
|
||||
|
||||
if (imageFile) {
|
||||
enqueueImageUpload({
|
||||
@ -462,13 +481,15 @@ export default function GroceryList() {
|
||||
source: "add_details",
|
||||
localItemId: newItem.id,
|
||||
});
|
||||
toast.info("Queued image upload", `Queued image upload for ${newItem.item_name || pendingItem.itemName}`);
|
||||
}
|
||||
}
|
||||
} catch (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 () => {
|
||||
if (!pendingItem) return;
|
||||
@ -496,12 +517,14 @@ export default function GroceryList() {
|
||||
|
||||
if (newItem) {
|
||||
setItems(prevItems => [...prevItems, newItem]);
|
||||
toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`);
|
||||
}
|
||||
} catch (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(() => {
|
||||
@ -519,23 +542,29 @@ export default function GroceryList() {
|
||||
const item = items.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
try {
|
||||
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
||||
|
||||
// If buying full quantity, remove from list
|
||||
if (quantity >= item.quantity) {
|
||||
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
||||
setItems(prevItems => prevItems.filter((existingItem) => existingItem.id !== id));
|
||||
} else {
|
||||
// If partial, fetch updated item
|
||||
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
||||
const updatedItem = response.data;
|
||||
|
||||
setItems(prevItems =>
|
||||
prevItems.map(i => i.id === id ? updatedItem : i)
|
||||
setItems((prevItems) =>
|
||||
prevItems.map((existingItem) => (existingItem.id === id ? updatedItem : existingItem))
|
||||
);
|
||||
}
|
||||
|
||||
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
|
||||
loadRecentlyBought();
|
||||
}, [activeHousehold?.id, activeStore?.id, items]);
|
||||
} 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]);
|
||||
|
||||
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
|
||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||
@ -554,11 +583,13 @@ export default function GroceryList() {
|
||||
source,
|
||||
localItemId: id,
|
||||
});
|
||||
toast.info("Queued image upload", `Queued image upload for ${itemName}`);
|
||||
} catch (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) => {
|
||||
@ -605,11 +636,14 @@ export default function GroceryList() {
|
||||
item.id === id ? { ...item, ...updatedItem } : item
|
||||
)
|
||||
);
|
||||
toast.success("Updated item", `Updated item ${itemName}`);
|
||||
} catch (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;
|
||||
}
|
||||
}, [activeHousehold?.id, activeStore?.id]);
|
||||
}, [activeHousehold?.id, activeStore?.id, toast]);
|
||||
|
||||
|
||||
const handleEditCancel = useCallback(() => {
|
||||
|
||||
158
frontend/src/pages/InviteLink.jsx
Normal file
158
frontend/src/pages/InviteLink.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,13 +1,17 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { loginRequest } from "../api/auth";
|
||||
import ErrorMessage from "../components/common/ErrorMessage";
|
||||
import FormInput from "../components/common/FormInput";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import useActionToast from "../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import "../styles/pages/Login.css";
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useContext(AuthContext);
|
||||
const toast = useActionToast();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@ -20,9 +24,12 @@ export default function Login() {
|
||||
try {
|
||||
const data = await loginRequest(username, password);
|
||||
login(data);
|
||||
window.location.href = "/";
|
||||
toast.success("Logged in", `Welcome back ${data?.username || username}`);
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || "Login failed");
|
||||
const message = getApiErrorMessage(err, "Login failed");
|
||||
setError(message);
|
||||
toast.error("Login failed", `Login failed: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -5,11 +5,14 @@ import { checkIfUserExists } from "../api/users";
|
||||
import ErrorMessage from "../components/common/ErrorMessage";
|
||||
import FormInput from "../components/common/FormInput";
|
||||
import { AuthContext } from "../context/AuthContext";
|
||||
import useActionToast from "../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import "../styles/pages/Register.css";
|
||||
|
||||
export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const { login } = useContext(AuthContext);
|
||||
const toast = useActionToast();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [username, setUsername] = useState("");
|
||||
@ -47,12 +50,16 @@ export default function Register() {
|
||||
|
||||
try {
|
||||
await registerRequest(username, password, name);
|
||||
toast.success("Registered account", `Created account for ${username}`);
|
||||
const data = await loginRequest(username, password);
|
||||
login(data);
|
||||
toast.success("Logged in", `Welcome ${data?.username || username}`);
|
||||
setSuccess("Account created! Redirecting to the grocery list...");
|
||||
setTimeout(() => navigate("/"), 2000);
|
||||
} 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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users";
|
||||
import { SettingsContext } from "../context/SettingsContext";
|
||||
import useActionToast from "../hooks/useActionToast";
|
||||
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||
import "../styles/pages/Settings.css";
|
||||
|
||||
|
||||
export default function Settings() {
|
||||
const { settings, updateSettings, resetSettings } = useContext(SettingsContext);
|
||||
const toast = useActionToast();
|
||||
const [activeTab, setActiveTab] = useState("appearance");
|
||||
const tabsRef = useRef(null);
|
||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||
@ -75,11 +78,14 @@ export default function Settings() {
|
||||
try {
|
||||
await updateCurrentUser(displayName);
|
||||
setAccountMessage({ type: "success", text: "Display name updated successfully!" });
|
||||
toast.success("Updated display name", `Updated display name to ${displayName}`);
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to update display name");
|
||||
setAccountMessage({
|
||||
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 {
|
||||
setLoadingProfile(false);
|
||||
}
|
||||
@ -105,14 +111,17 @@ export default function Settings() {
|
||||
try {
|
||||
await changePassword(currentPassword, newPassword);
|
||||
setAccountMessage({ type: "success", text: "Password changed successfully!" });
|
||||
toast.success("Changed password", "Changed password successfully");
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} catch (error) {
|
||||
const message = getApiErrorMessage(error, "Failed to change password");
|
||||
setAccountMessage({
|
||||
type: "error",
|
||||
text: error.response?.data?.error || "Failed to change password"
|
||||
text: message
|
||||
});
|
||||
toast.error("Change password failed", `Change password failed: ${message}`);
|
||||
} finally {
|
||||
setLoadingPassword(false);
|
||||
}
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
|
||||
.add-item-form-assignee-toggle {
|
||||
flex: 0 0 auto;
|
||||
width: 134px;
|
||||
width: 112px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@ -201,7 +201,7 @@
|
||||
}
|
||||
|
||||
.add-item-form-assignee-toggle {
|
||||
width: 120px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.add-item-form-quantity-control {
|
||||
|
||||
144
frontend/src/styles/components/ConfirmSlideModal.css
Normal file
144
frontend/src/styles/components/ConfirmSlideModal.css
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -6,9 +6,11 @@
|
||||
|
||||
.store-tabs-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
overflow-x: auto;
|
||||
padding: 0.5rem 1rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.store-tabs-container::-webkit-scrollbar {
|
||||
@ -23,6 +25,7 @@
|
||||
.store-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: transparent;
|
||||
@ -34,6 +37,7 @@
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.store-tab:hover {
|
||||
|
||||
@ -56,6 +56,12 @@
|
||||
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 {
|
||||
color: var(--color-text-inverse);
|
||||
background: transparent;
|
||||
|
||||
123
frontend/src/styles/components/UploadToaster.css
Normal file
123
frontend/src/styles/components/UploadToaster.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -85,6 +85,78 @@
|
||||
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-list {
|
||||
display: flex;
|
||||
@ -244,4 +316,23 @@
|
||||
text-align: center;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
77
frontend/src/styles/pages/InviteLink.css
Normal file
77
frontend/src/styles/pages/InviteLink.css
Normal 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;
|
||||
}
|
||||
}
|
||||
10
frontend/tests/auth-smoke.spec.ts
Normal file
10
frontend/tests/auth-smoke.spec.ts
Normal 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();
|
||||
});
|
||||
272
frontend/tests/toast-notifications.spec.ts
Normal file
272
frontend/tests/toast-notifications.spec.ts
Normal 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
5
jest.config.cjs
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
roots: ["<rootDir>/backend/tests"],
|
||||
clearMocks: true,
|
||||
};
|
||||
@ -2,7 +2,14 @@
|
||||
"scripts": {
|
||||
"db:migrate": "node scripts/db-migrate.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": {
|
||||
"cross-env": "^10.1.0",
|
||||
|
||||
@ -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:status` to view applied/pending migrations.
|
||||
- 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`.
|
||||
|
||||
## 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.
|
||||
|
||||
@ -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
|
||||
ALTER TABLE grocery_list
|
||||
ADD COLUMN item_image BYTEA,
|
||||
ADD COLUMN image_mime_type VARCHAR(50);
|
||||
ADD COLUMN IF NOT EXISTS item_image BYTEA,
|
||||
ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50);
|
||||
|
||||
-- Optional: Add index for faster queries when filtering by items with images
|
||||
CREATE INDEX 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`.
|
||||
-- Index to speed up queries that filter by rows with images.
|
||||
CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image
|
||||
ON grocery_list ((item_image IS NOT NULL));
|
||||
|
||||
12
packages/db/migrations/stale-files.json
Normal file
12
packages/db/migrations/stale-files.json
Normal 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"
|
||||
]
|
||||
}
|
||||
165
packages/db/migrations/zz_group_invites_and_join_policies.sql
Normal file
165
packages/db/migrations/zz_group_invites_and_join_policies.sql
Normal 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
32
rebuild-dev.sh
Normal 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 "$@"
|
||||
@ -11,6 +11,48 @@ const migrationsDir = path.resolve(
|
||||
"db",
|
||||
"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() {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
@ -35,9 +77,11 @@ function ensureMigrationsDir() {
|
||||
|
||||
function getMigrationFiles() {
|
||||
ensureMigrationsDir();
|
||||
const skipped = getSkippedMigrations();
|
||||
return fs
|
||||
.readdirSync(migrationsDir)
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.filter((file) => !skipped.has(file))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
@ -104,5 +148,6 @@ module.exports = {
|
||||
ensureSchemaMigrationsTable,
|
||||
getAppliedMigrations,
|
||||
getMigrationFiles,
|
||||
getSkippedMigrations,
|
||||
migrationsDir,
|
||||
};
|
||||
|
||||
69
scripts/db-migrate-new.js
Normal file
69
scripts/db-migrate-new.js
Normal 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);
|
||||
}
|
||||
@ -15,6 +15,14 @@ function main() {
|
||||
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();
|
||||
ensurePsql();
|
||||
ensureSchemaMigrationsTable(databaseUrl);
|
||||
|
||||
187
scripts/db-stale-sql-tracker.js
Normal file
187
scripts/db-stale-sql-tracker.js
Normal 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user