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)
|
# Build output (if using a bundler or React later)
|
||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
playwright-report/
|
||||||
|
test-results/
|
||||||
|
.npm-cache/
|
||||||
|
.playwright-browsers/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
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`).
|
- Receipt images stored in `receipts` (`bytea`).
|
||||||
- Entries list endpoints must NEVER return receipt bytes.
|
- Entries list endpoints must NEVER return receipt bytes.
|
||||||
- API responses must include `request_id`; audit logs must include `request_id`.
|
- API responses must include `request_id`; audit logs must include `request_id`.
|
||||||
|
- Frontend actions that manipulate database state must show a toast/bubble notification with basic outcome info (action + target + success/failure).
|
||||||
|
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`).
|
||||||
|
|
||||||
## Architecture boundaries (follow existing patterns; do not invent)
|
## Architecture boundaries (follow existing patterns; do not invent)
|
||||||
1) API routes: `app/api/**/route.ts`
|
1) API routes: `app/api/**/route.ts`
|
||||||
|
|||||||
@ -154,7 +154,8 @@ For `app/api/**/[param]/route.ts`:
|
|||||||
- Mouse: hover affordance on interactive rows/cards.
|
- Mouse: hover affordance on interactive rows/cards.
|
||||||
- Tap targets remain >= 40px on mobile.
|
- Tap targets remain >= 40px on mobile.
|
||||||
- Modal overlays must close on outside click/tap.
|
- Modal overlays must close on outside click/tap.
|
||||||
- Use bubble notifications for main actions (create/update/delete/join).
|
- For every frontend action that manipulates database state, show a toast/bubble notification with basic outcome details (action + target + success/failure).
|
||||||
|
- Progress-type notifications must reuse the existing upload toaster pattern (`UploadQueueContext` + `UploadToaster`) for consistency.
|
||||||
- Add Playwright UI tests for new UI features and critical flows.
|
- Add Playwright UI tests for new UI features and critical flows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@ -2,6 +2,8 @@ FROM node:20-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
|
|||||||
@ -63,6 +63,9 @@ app.use("/households", householdsRoutes);
|
|||||||
const storesRoutes = require("./routes/stores.routes");
|
const storesRoutes = require("./routes/stores.routes");
|
||||||
app.use("/stores", storesRoutes);
|
app.use("/stores", storesRoutes);
|
||||||
|
|
||||||
|
const groupInvitesRoutes = require("./routes/group-invites.routes");
|
||||||
|
app.use("/api", groupInvitesRoutes);
|
||||||
|
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
if (res.headersSent) {
|
if (res.headersSent) {
|
||||||
return next(err);
|
return next(err);
|
||||||
|
|||||||
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 { userId } = req.params;
|
||||||
const { role } = req.body;
|
const { role } = req.body;
|
||||||
|
|
||||||
if (!role || !['admin', 'user'].includes(role)) {
|
if (!role || !['admin', 'member'].includes(role)) {
|
||||||
return sendError(res, 400, "Invalid role. Must be 'admin' or 'user'");
|
return sendError(res, 400, "Invalid role. Must be 'admin' or 'member'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Can't change own role
|
// Can't change own role
|
||||||
@ -174,6 +174,14 @@ exports.updateMemberRole = async (req, res) => {
|
|||||||
return sendError(res, 400, "Cannot change your own role");
|
return sendError(res, 400, "Cannot change your own role");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
|
||||||
|
if (!targetRole) {
|
||||||
|
return sendError(res, 404, "Member not found");
|
||||||
|
}
|
||||||
|
if (targetRole === "owner") {
|
||||||
|
return sendError(res, 403, "Owner role cannot be changed");
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await householdModel.updateMemberRole(
|
const updated = await householdModel.updateMemberRole(
|
||||||
req.params.householdId,
|
req.params.householdId,
|
||||||
userId,
|
userId,
|
||||||
@ -197,8 +205,13 @@ exports.removeMember = async (req, res) => {
|
|||||||
const targetUserId = parseInt(userId);
|
const targetUserId = parseInt(userId);
|
||||||
|
|
||||||
// Allow users to remove themselves, or admins to remove others
|
// Allow users to remove themselves, or admins to remove others
|
||||||
if (targetUserId !== req.user.id && req.household.role !== 'admin') {
|
if (targetUserId !== req.user.id && !["owner", "admin"].includes(req.household.role)) {
|
||||||
return sendError(res, 403, "Only admins can remove other members");
|
return sendError(res, 403, "Only admins or owners can remove other members");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRole = await householdModel.getUserRole(req.params.householdId, userId);
|
||||||
|
if (targetRole === "owner") {
|
||||||
|
return sendError(res, 403, "Owner cannot be removed");
|
||||||
}
|
}
|
||||||
|
|
||||||
await householdModel.removeMember(req.params.householdId, userId);
|
await householdModel.removeMember(req.params.householdId, userId);
|
||||||
|
|||||||
@ -60,7 +60,12 @@ exports.addItem = async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") {
|
if (added_for_user_id !== undefined && added_for_user_id !== null && String(added_for_user_id).trim() !== "") {
|
||||||
const parsedUserId = Number.parseInt(String(added_for_user_id), 10);
|
const rawAddedForUserId = String(added_for_user_id).trim();
|
||||||
|
if (!/^\d+$/.test(rawAddedForUserId)) {
|
||||||
|
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedUserId = Number.parseInt(rawAddedForUserId, 10);
|
||||||
|
|
||||||
if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) {
|
if (!Number.isInteger(parsedUserId) || parsedUserId <= 0) {
|
||||||
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
return sendError(res, 400, "Added-for user ID must be a positive integer");
|
||||||
|
|||||||
@ -54,8 +54,8 @@ exports.requireHouseholdRole = (...allowedRoles) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Middleware to require admin role in household
|
// Middleware to require admin/owner role in household
|
||||||
exports.requireHouseholdAdmin = exports.requireHouseholdRole('admin');
|
exports.requireHouseholdAdmin = exports.requireHouseholdRole('owner', 'admin');
|
||||||
|
|
||||||
// Middleware to check store access (household must have store)
|
// Middleware to check store access (household must have store)
|
||||||
exports.storeAccess = async (req, res, next) => {
|
exports.storeAccess = async (req, res, next) => {
|
||||||
|
|||||||
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";
|
return req.ip || req.socket?.remoteAddress || "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRateLimit({ keyPrefix, windowMs, max, message }) {
|
function createRateLimit({ keyPrefix, windowMs, max, message, keyFn }) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@ -26,7 +26,8 @@ function createRateLimit({ keyPrefix, windowMs, max, message }) {
|
|||||||
pruneExpired(now);
|
pruneExpired(now);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `${keyPrefix}:${getClientIp(req)}`;
|
const suffix = typeof keyFn === "function" ? keyFn(req) : getClientIp(req);
|
||||||
|
const key = `${keyPrefix}:${suffix || "unknown"}`;
|
||||||
const existing = buckets.get(key);
|
const existing = buckets.get(key);
|
||||||
const bucket =
|
const bucket =
|
||||||
!existing || existing.resetAt <= now
|
!existing || existing.resetAt <= now
|
||||||
|
|||||||
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
|
// Add creator as admin
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO household_members (household_id, user_id, role)
|
`INSERT INTO household_members (household_id, user_id, role)
|
||||||
VALUES ($1, $2, 'admin')`,
|
VALUES ($1, $2, 'owner')`,
|
||||||
[result.rows[0].id, createdBy]
|
[result.rows[0].id, createdBy]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -118,7 +118,7 @@ exports.joinHousehold = async (inviteCode, userId) => {
|
|||||||
// Add as user role
|
// Add as user role
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO household_members (household_id, user_id, role)
|
`INSERT INTO household_members (household_id, user_id, role)
|
||||||
VALUES ($1, $2, 'user')`,
|
VALUES ($1, $2, 'member')`,
|
||||||
[household.id, userId]
|
[household.id, userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -140,8 +140,9 @@ exports.getHouseholdMembers = async (householdId) => {
|
|||||||
WHERE hm.household_id = $1
|
WHERE hm.household_id = $1
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE hm.role
|
CASE hm.role
|
||||||
WHEN 'admin' THEN 1
|
WHEN 'owner' THEN 1
|
||||||
WHEN 'user' THEN 2
|
WHEN 'admin' THEN 2
|
||||||
|
WHEN 'member' THEN 3
|
||||||
END,
|
END,
|
||||||
hm.joined_at ASC`,
|
hm.joined_at ASC`,
|
||||||
[householdId]
|
[householdId]
|
||||||
|
|||||||
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);
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("records history using request user when added_for_user_id is not provided", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("records history using request user when added_for_user_id is blank", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1", added_for_user_id: " " },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(householdModel.isHouseholdMember).not.toHaveBeenCalled();
|
||||||
|
expect(List.addOrUpdateItem).toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).toHaveBeenCalledWith(42, "1", 7);
|
||||||
|
expect(res.status).not.toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects invalid added_for_user_id", async () => {
|
test("rejects invalid added_for_user_id", async () => {
|
||||||
const req = {
|
const req = {
|
||||||
params: { householdId: "1", storeId: "2" },
|
params: { householdId: "1", storeId: "2" },
|
||||||
@ -73,6 +107,29 @@ describe("lists.controller.v2 addItem", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects malformed numeric-looking added_for_user_id", async () => {
|
||||||
|
const req = {
|
||||||
|
params: { householdId: "1", storeId: "2" },
|
||||||
|
body: { item_name: "milk", quantity: "1", added_for_user_id: "9abc" },
|
||||||
|
user: { id: 7 },
|
||||||
|
processedImage: null,
|
||||||
|
};
|
||||||
|
const res = createResponse();
|
||||||
|
|
||||||
|
await controller.addItem(req, res);
|
||||||
|
|
||||||
|
expect(List.addOrUpdateItem).not.toHaveBeenCalled();
|
||||||
|
expect(List.addHistoryRecord).not.toHaveBeenCalled();
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: "Added-for user ID must be a positive integer",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects added_for_user_id when target user is not household member", async () => {
|
test("rejects added_for_user_id when target user is not household member", async () => {
|
||||||
householdModel.isHouseholdMember.mockResolvedValue(false);
|
householdModel.isHouseholdMember.mockResolvedValue(false);
|
||||||
|
|
||||||
|
|||||||
6
debug.log
Normal file
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
|
#!/bin/bash
|
||||||
# Quick script to rebuild Docker Compose dev environment
|
set -euo pipefail
|
||||||
|
|
||||||
echo "Stopping containers and removing volumes..."
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
docker-compose -f docker-compose.dev.yml down -v
|
exec "$SCRIPT_DIR/rebuild-dev.sh" "$@"
|
||||||
|
|
||||||
echo "Rebuilding and starting containers..."
|
|
||||||
docker-compose -f docker-compose.dev.yml up --build
|
|
||||||
|
|||||||
@ -16,10 +16,19 @@ This project uses an external on-prem Postgres database. Migration files are can
|
|||||||
- `npm run db:migrate:status`
|
- `npm run db:migrate:status`
|
||||||
- Fail if pending migrations exist:
|
- Fail if pending migrations exist:
|
||||||
- `npm run db:migrate:verify`
|
- `npm run db:migrate:verify`
|
||||||
|
- Create a new migration file:
|
||||||
|
- `npm run db:migrate:new -- <migration-name>`
|
||||||
|
- Track stale legacy SQL in `backend/migrations`:
|
||||||
|
- `npm run db:migrate:stale`
|
||||||
|
- Fail when stale legacy SQL exists:
|
||||||
|
- `npm run db:migrate:stale:check`
|
||||||
|
|
||||||
## Active migration set
|
## Active migration set
|
||||||
Migration files are applied in lexicographic filename order from `packages/db/migrations`.
|
Migration files are applied in lexicographic filename order from `packages/db/migrations`.
|
||||||
|
|
||||||
|
`backend/migrations` is legacy reference-only and not part of canonical execution.
|
||||||
|
`packages/db/migrations/stale-files.json` is the source of truth for canonical files that are intentionally stale/ignored.
|
||||||
|
|
||||||
Current baseline files:
|
Current baseline files:
|
||||||
- `add_display_name_column.sql`
|
- `add_display_name_column.sql`
|
||||||
- `add_image_columns.sql`
|
- `add_image_columns.sql`
|
||||||
@ -37,9 +46,11 @@ Applied migrations are recorded in:
|
|||||||
## Expected operator flow
|
## Expected operator flow
|
||||||
1. Check status:
|
1. Check status:
|
||||||
- `npm run db:migrate:status`
|
- `npm run db:migrate:status`
|
||||||
2. Apply pending:
|
2. If a new implementation needs schema changes, create a new file:
|
||||||
|
- `npm run db:migrate:new -- <migration-name>`
|
||||||
|
3. Apply pending:
|
||||||
- `npm run db:migrate`
|
- `npm run db:migrate`
|
||||||
3. Verify clean state:
|
4. Verify clean state:
|
||||||
- `npm run db:migrate:verify`
|
- `npm run db:migrate:verify`
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@ -49,3 +60,8 @@ Applied migrations are recorded in:
|
|||||||
- Install PostgreSQL client tools and retry.
|
- Install PostgreSQL client tools and retry.
|
||||||
- SQL failure:
|
- SQL failure:
|
||||||
- Fix migration SQL and rerun; only successful files are recorded in `schema_migrations`.
|
- Fix migration SQL and rerun; only successful files are recorded in `schema_migrations`.
|
||||||
|
- Skip known stale SQL files for a specific environment:
|
||||||
|
- Set `DB_MIGRATE_SKIP_FILES` to a comma-separated filename list.
|
||||||
|
- Example: `DB_MIGRATE_SKIP_FILES=add_modified_on_column.sql,add_image_columns.sql`
|
||||||
|
- Temporarily include files listed in `stale-files.json`:
|
||||||
|
- Set `DB_MIGRATE_INCLUDE_STALE=true` before running migration commands.
|
||||||
|
|||||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.2",
|
||||||
@ -981,6 +982,21 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-beta.47",
|
"version": "1.0.0-beta.47",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz",
|
||||||
@ -2915,6 +2931,50 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||||
|
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.6",
|
"version": "8.5.6",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
|||||||
@ -7,7 +7,10 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
|
"test:e2e:ui": "playwright test --ui"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
@ -16,6 +19,7 @@
|
|||||||
"react-router-dom": "^7.9.6"
|
"react-router-dom": "^7.9.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.52.0",
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
"@types/node": "^24.10.0",
|
"@types/node": "^24.10.0",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
|
|||||||
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 { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
import { ROLES } from "./constants/roles";
|
import { ROLES } from "./constants/roles";
|
||||||
import { AuthProvider } from "./context/AuthContext.jsx";
|
import { AuthProvider } from "./context/AuthContext.jsx";
|
||||||
|
import { ActionToastProvider } from "./context/ActionToastContext.jsx";
|
||||||
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
import { ConfigProvider } from "./context/ConfigContext.jsx";
|
||||||
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
|
import { HouseholdProvider } from "./context/HouseholdContext.jsx";
|
||||||
|
import { UploadQueueProvider } from "./context/UploadQueueContext.jsx";
|
||||||
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
import { SettingsProvider } from "./context/SettingsContext.jsx";
|
||||||
import { StoreProvider } from "./context/StoreContext.jsx";
|
import { StoreProvider } from "./context/StoreContext.jsx";
|
||||||
|
|
||||||
@ -12,26 +14,28 @@ import Login from "./pages/Login.jsx";
|
|||||||
import Manage from "./pages/Manage.jsx";
|
import Manage from "./pages/Manage.jsx";
|
||||||
import Register from "./pages/Register.jsx";
|
import Register from "./pages/Register.jsx";
|
||||||
import Settings from "./pages/Settings.jsx";
|
import Settings from "./pages/Settings.jsx";
|
||||||
|
import InviteLink from "./pages/InviteLink.jsx";
|
||||||
|
|
||||||
import AppLayout from "./components/layout/AppLayout.jsx";
|
import AppLayout from "./components/layout/AppLayout.jsx";
|
||||||
|
import UploadToaster from "./components/common/UploadToaster.jsx";
|
||||||
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
import PrivateRoute from "./utils/PrivateRoute.jsx";
|
||||||
|
|
||||||
import RoleGuard from "./utils/RoleGuard.jsx";
|
import RoleGuard from "./utils/RoleGuard.jsx";
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ConfigProvider>
|
<ConfigProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<HouseholdProvider>
|
<HouseholdProvider>
|
||||||
<StoreProvider>
|
<StoreProvider>
|
||||||
|
<UploadQueueProvider>
|
||||||
|
<ActionToastProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
|
||||||
{/* Public route */}
|
{/* Public route */}
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/invite/:token" element={<InviteLink />} />
|
||||||
|
|
||||||
{/* Private routes with layout */}
|
{/* Private routes with layout */}
|
||||||
<Route
|
<Route
|
||||||
@ -54,10 +58,12 @@ function App() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
|
<UploadToaster />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
|
</ActionToastProvider>
|
||||||
|
</UploadQueueProvider>
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
</HouseholdProvider>
|
</HouseholdProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@ -56,3 +56,38 @@ export const updateMemberRole = (householdId, userId, role) =>
|
|||||||
*/
|
*/
|
||||||
export const removeMember = (householdId, userId) =>
|
export const removeMember = (householdId, userId) =>
|
||||||
api.delete(`/households/${householdId}/members/${userId}`);
|
api.delete(`/households/${householdId}/members/${userId}`);
|
||||||
|
|
||||||
|
function groupHeaders(groupId) {
|
||||||
|
return {
|
||||||
|
headers: {
|
||||||
|
"x-group-id": String(groupId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getGroupInviteLinks = (groupId) =>
|
||||||
|
api.get("/api/groups/invites", groupHeaders(groupId));
|
||||||
|
|
||||||
|
export const createGroupInviteLink = (groupId, payload) =>
|
||||||
|
api.post("/api/groups/invites", payload, groupHeaders(groupId));
|
||||||
|
|
||||||
|
export const revokeGroupInviteLink = (groupId, linkId) =>
|
||||||
|
api.post("/api/groups/invites/revoke", { linkId }, groupHeaders(groupId));
|
||||||
|
|
||||||
|
export const reviveGroupInviteLink = (groupId, linkId, ttlDays) =>
|
||||||
|
api.post("/api/groups/invites/revive", { linkId, ttlDays }, groupHeaders(groupId));
|
||||||
|
|
||||||
|
export const deleteGroupInviteLink = (groupId, linkId) =>
|
||||||
|
api.post("/api/groups/invites/delete", { linkId }, groupHeaders(groupId));
|
||||||
|
|
||||||
|
export const getGroupJoinPolicy = (groupId) =>
|
||||||
|
api.get("/api/groups/join-policy", groupHeaders(groupId));
|
||||||
|
|
||||||
|
export const setGroupJoinPolicy = (groupId, joinPolicy) =>
|
||||||
|
api.post("/api/groups/join-policy", { joinPolicy }, groupHeaders(groupId));
|
||||||
|
|
||||||
|
export const getInviteLinkSummary = (token) =>
|
||||||
|
api.get(`/api/invite-links/${encodeURIComponent(token)}`);
|
||||||
|
|
||||||
|
export const acceptInviteLink = (token) =>
|
||||||
|
api.post(`/api/invite-links/${encodeURIComponent(token)}`);
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores";
|
import { createStore, deleteStore, getAllStores, updateStore } from "../../api/stores";
|
||||||
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import "../../styles/components/admin/StoreManagement.css";
|
import "../../styles/components/admin/StoreManagement.css";
|
||||||
|
|
||||||
export default function StoreManagement() {
|
export default function StoreManagement() {
|
||||||
|
const toast = useActionToast();
|
||||||
const [stores, setStores] = useState([]);
|
const [stores, setStores] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingStore, setEditingStore] = useState(null);
|
const [editingStore, setEditingStore] = useState(null);
|
||||||
@ -24,7 +27,8 @@ export default function StoreManagement() {
|
|||||||
setStores(response.data);
|
setStores(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load stores:", error);
|
console.error("Failed to load stores:", error);
|
||||||
alert("Failed to load stores");
|
const message = getApiErrorMessage(error, "Failed to load stores");
|
||||||
|
toast.error("Load stores failed", `Load stores failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -38,12 +42,14 @@ export default function StoreManagement() {
|
|||||||
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
||||||
await createStore(formData.name, zonesJson);
|
await createStore(formData.name, zonesJson);
|
||||||
await loadStores();
|
await loadStores();
|
||||||
|
toast.success("Created store", `Created store ${formData.name.trim()}`);
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setFormData({ name: "", zones: [] });
|
setFormData({ name: "", zones: [] });
|
||||||
setNewZone("");
|
setNewZone("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to create store:", error);
|
console.error("Failed to create store:", error);
|
||||||
alert(error.response?.data?.error || "Failed to create store");
|
const message = getApiErrorMessage(error, "Failed to create store");
|
||||||
|
toast.error("Create store failed", `Create store failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -55,12 +61,14 @@ export default function StoreManagement() {
|
|||||||
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
const zonesJson = formData.zones.length > 0 ? JSON.stringify(formData.zones) : null;
|
||||||
await updateStore(editingStore.id, formData.name, zonesJson);
|
await updateStore(editingStore.id, formData.name, zonesJson);
|
||||||
await loadStores();
|
await loadStores();
|
||||||
|
toast.success("Updated store", `Updated store ${formData.name.trim()}`);
|
||||||
setEditingStore(null);
|
setEditingStore(null);
|
||||||
setFormData({ name: "", zones: [] });
|
setFormData({ name: "", zones: [] });
|
||||||
setNewZone("");
|
setNewZone("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update store:", error);
|
console.error("Failed to update store:", error);
|
||||||
alert(error.response?.data?.error || "Failed to update store");
|
const message = getApiErrorMessage(error, "Failed to update store");
|
||||||
|
toast.error("Update store failed", `Update store failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -70,9 +78,11 @@ export default function StoreManagement() {
|
|||||||
try {
|
try {
|
||||||
await deleteStore(storeId);
|
await deleteStore(storeId);
|
||||||
await loadStores();
|
await loadStores();
|
||||||
|
toast.success("Deleted store", `Deleted store ${storeName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete store:", error);
|
console.error("Failed to delete store:", error);
|
||||||
alert(error.response?.data?.error || "Failed to delete store");
|
const message = getApiErrorMessage(error, "Failed to delete store");
|
||||||
|
toast.error("Delete store failed", `Delete store failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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();
|
e.preventDefault();
|
||||||
if (!itemName.trim()) return;
|
if (!itemName.trim()) return;
|
||||||
|
|
||||||
const targetUserId = assignmentMode === "others" ? assignedUserId : null;
|
if (assignmentMode === "others" && assignedUserId == null) {
|
||||||
|
if (otherMembers.length > 0) {
|
||||||
|
setShowAssignModal(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUserId = assignmentMode === "others" ? Number(assignedUserId) : null;
|
||||||
onAdd(itemName, quantity, targetUserId);
|
onAdd(itemName, quantity, targetUserId);
|
||||||
setItemName("");
|
setItemName("");
|
||||||
setQuantity(1);
|
setQuantity(1);
|
||||||
@ -124,7 +131,7 @@ export default function AddItemForm({
|
|||||||
value={assignmentMode}
|
value={assignmentMode}
|
||||||
ariaLabel="Item assignment mode"
|
ariaLabel="Item assignment mode"
|
||||||
className="tbg-group add-item-form-assignee-toggle"
|
className="tbg-group add-item-form-assignee-toggle"
|
||||||
sizeClassName="tbg-size-xs"
|
sizeClassName="tbg-size-xxs"
|
||||||
options={[
|
options={[
|
||||||
{ value: "me", label: "Me" },
|
{ value: "me", label: "Me" },
|
||||||
{ value: "others", label: "Others", disabled: otherMembers.length === 0 }
|
{ value: "others", label: "Others", disabled: otherMembers.length === 0 }
|
||||||
|
|||||||
@ -1,13 +1,17 @@
|
|||||||
import "../../styles/components/Navbar.css";
|
import "../../styles/components/Navbar.css";
|
||||||
|
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { logoutRequest } from "../../api/auth";
|
import { logoutRequest } from "../../api/auth";
|
||||||
import { AuthContext } from "../../context/AuthContext";
|
import { AuthContext } from "../../context/AuthContext";
|
||||||
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import HouseholdSwitcher from "../household/HouseholdSwitcher";
|
import HouseholdSwitcher from "../household/HouseholdSwitcher";
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { role, logout, username } = useContext(AuthContext);
|
const { role, logout, username } = useContext(AuthContext);
|
||||||
|
const toast = useActionToast();
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
|
|
||||||
const closeMenus = () => {
|
const closeMenus = () => {
|
||||||
@ -15,14 +19,23 @@ export default function Navbar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
|
let loggedOutRemotely = true;
|
||||||
|
let fallbackReason = "";
|
||||||
try {
|
try {
|
||||||
await logoutRequest();
|
await logoutRequest();
|
||||||
} catch (_) {
|
} catch (error) {
|
||||||
// Clear local auth state even if server logout fails.
|
// Clear local auth state even if server logout fails.
|
||||||
|
loggedOutRemotely = false;
|
||||||
|
fallbackReason = getApiErrorMessage(error, "Unable to end server session");
|
||||||
} finally {
|
} finally {
|
||||||
logout();
|
logout();
|
||||||
closeMenus();
|
closeMenus();
|
||||||
window.location.href = "/login";
|
if (loggedOutRemotely) {
|
||||||
|
toast.success("Logged out", "Logged out successfully");
|
||||||
|
} else {
|
||||||
|
toast.info("Logged out locally", `Server logout failed: ${fallbackReason}`);
|
||||||
|
}
|
||||||
|
navigate("/login");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,39 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import { createHousehold, joinHousehold } from "../../api/households";
|
import { createHousehold, joinHousehold } from "../../api/households";
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import "../../styles/components/manage/CreateJoinHousehold.css";
|
import "../../styles/components/manage/CreateJoinHousehold.css";
|
||||||
|
|
||||||
export default function CreateJoinHousehold({ onClose }) {
|
export default function CreateJoinHousehold({ onClose }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const toast = useActionToast();
|
||||||
const { refreshHouseholds } = useContext(HouseholdContext);
|
const { refreshHouseholds } = useContext(HouseholdContext);
|
||||||
const [mode, setMode] = useState("create"); // "create" or "join"
|
const [mode, setMode] = useState("create");
|
||||||
const [householdName, setHouseholdName] = useState("");
|
const [householdName, setHouseholdName] = useState("");
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const extractInviteToken = (value) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const directMatch = trimmed.match(/^\/?invite\/([a-zA-Z0-9]+)$/);
|
||||||
|
if (directMatch) return directMatch[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed, window.location.origin);
|
||||||
|
const urlMatch = parsed.pathname.match(/^\/invite\/([a-zA-Z0-9]+)$/);
|
||||||
|
if (urlMatch) return urlMatch[1];
|
||||||
|
} catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreate = async (e) => {
|
const handleCreate = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!householdName.trim()) return;
|
if (!householdName.trim()) return;
|
||||||
@ -21,10 +44,13 @@ export default function CreateJoinHousehold({ onClose }) {
|
|||||||
try {
|
try {
|
||||||
await createHousehold(householdName);
|
await createHousehold(householdName);
|
||||||
await refreshHouseholds();
|
await refreshHouseholds();
|
||||||
|
toast.success("Created household", `Created household ${householdName.trim()}`);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create household:", err);
|
console.error("Failed to create household:", err);
|
||||||
setError(err.response?.data?.message || "Failed to create household");
|
const message = getApiErrorMessage(err, "Failed to create household");
|
||||||
|
setError(message);
|
||||||
|
toast.error("Create household failed", `Create household failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -38,12 +64,23 @@ export default function CreateJoinHousehold({ onClose }) {
|
|||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const inviteToken = extractInviteToken(inviteCode);
|
||||||
|
if (inviteToken) {
|
||||||
|
toast.info("Invite link detected", "Opening invite details");
|
||||||
|
onClose();
|
||||||
|
navigate(`/invite/${inviteToken}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await joinHousehold(inviteCode);
|
await joinHousehold(inviteCode);
|
||||||
await refreshHouseholds();
|
await refreshHouseholds();
|
||||||
|
toast.success("Joined household", "Joined household successfully");
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to join household:", err);
|
console.error("Failed to join household:", err);
|
||||||
setError(err.response?.data?.message || "Failed to join household");
|
const message = getApiErrorMessage(err, "Failed to join household");
|
||||||
|
setError(message);
|
||||||
|
toast.error("Join household failed", `Join household failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -54,7 +91,7 @@ export default function CreateJoinHousehold({ onClose }) {
|
|||||||
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
|
<div className="create-join-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>Household</h2>
|
<h2>Household</h2>
|
||||||
<button className="close-btn" onClick={onClose}>×</button>
|
<button className="close-btn" onClick={onClose}>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mode-tabs">
|
<div className="mode-tabs">
|
||||||
@ -100,18 +137,18 @@ export default function CreateJoinHousehold({ onClose }) {
|
|||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleJoin} className="household-form">
|
<form onSubmit={handleJoin} className="household-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="inviteCode">Invite Code</label>
|
<label htmlFor="inviteCode">Invite Code or Link</label>
|
||||||
<input
|
<input
|
||||||
id="inviteCode"
|
id="inviteCode"
|
||||||
type="text"
|
type="text"
|
||||||
value={inviteCode}
|
value={inviteCode}
|
||||||
onChange={(e) => setInviteCode(e.target.value)}
|
onChange={(e) => setInviteCode(e.target.value)}
|
||||||
placeholder="Enter invite code"
|
placeholder="Invite code or /invite URL"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="form-hint">
|
<p className="form-hint">
|
||||||
Ask the household admin for the invite code
|
Paste a raw invite code or full invite link URL
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
|
|||||||
@ -1,30 +1,59 @@
|
|||||||
import React, { useContext, useEffect, useState } from "react";
|
import React, { useContext, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
|
createGroupInviteLink,
|
||||||
|
deleteGroupInviteLink,
|
||||||
deleteHousehold,
|
deleteHousehold,
|
||||||
|
getGroupInviteLinks,
|
||||||
|
getGroupJoinPolicy,
|
||||||
getHouseholdMembers,
|
getHouseholdMembers,
|
||||||
refreshInviteCode,
|
refreshInviteCode,
|
||||||
removeMember,
|
removeMember,
|
||||||
|
revokeGroupInviteLink,
|
||||||
|
reviveGroupInviteLink,
|
||||||
|
setGroupJoinPolicy,
|
||||||
updateHousehold,
|
updateHousehold,
|
||||||
updateMemberRole
|
updateMemberRole
|
||||||
} from "../../api/households";
|
} from "../../api/households";
|
||||||
|
import { ToggleButtonGroup } from "../common";
|
||||||
|
import ConfirmSlideModal from "../modals/ConfirmSlideModal";
|
||||||
import { AuthContext } from "../../context/AuthContext";
|
import { AuthContext } from "../../context/AuthContext";
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import "../../styles/components/manage/ManageHousehold.css";
|
import "../../styles/components/manage/ManageHousehold.css";
|
||||||
|
|
||||||
|
const JOIN_POLICY_OPTIONS = [
|
||||||
|
{ label: "Disabled", value: "NOT_ACCEPTING" },
|
||||||
|
{ label: "Auto", value: "AUTO_ACCEPT" },
|
||||||
|
{ label: "Manual", value: "APPROVAL_REQUIRED" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ManageHousehold() {
|
export default function ManageHousehold() {
|
||||||
const { userId } = useContext(AuthContext);
|
const { userId } = useContext(AuthContext);
|
||||||
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
|
const { activeHousehold, refreshHouseholds } = useContext(HouseholdContext);
|
||||||
|
const toast = useActionToast();
|
||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingName, setEditingName] = useState(false);
|
const [editingName, setEditingName] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
const [showInviteCode, setShowInviteCode] = useState(false);
|
const [showInviteCode, setShowInviteCode] = useState(false);
|
||||||
|
const [joinPolicy, setJoinPolicyValue] = useState("NOT_ACCEPTING");
|
||||||
|
const [inviteLinks, setInviteLinks] = useState([]);
|
||||||
|
const [inviteLoading, setInviteLoading] = useState(false);
|
||||||
|
const [inviteError, setInviteError] = useState("");
|
||||||
|
const [ttlDays, setTtlDays] = useState(7);
|
||||||
|
const [singleUseMode, setSingleUseMode] = useState("UNLIMITED");
|
||||||
|
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
||||||
|
|
||||||
const isAdmin = activeHousehold?.role === "admin";
|
const isManager = ["owner", "admin"].includes(activeHousehold?.role);
|
||||||
|
const isMemberOnly = activeHousehold?.role === "member";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMembers();
|
loadMembers();
|
||||||
}, [activeHousehold?.id]);
|
if (isManager) {
|
||||||
|
loadJoinAndInvites();
|
||||||
|
}
|
||||||
|
}, [activeHousehold?.id, isManager]);
|
||||||
|
|
||||||
const loadMembers = async () => {
|
const loadMembers = async () => {
|
||||||
if (!activeHousehold?.id) return;
|
if (!activeHousehold?.id) return;
|
||||||
@ -39,6 +68,147 @@ export default function ManageHousehold() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const loadJoinAndInvites = async () => {
|
||||||
|
if (!activeHousehold?.id || !isManager) return;
|
||||||
|
setInviteLoading(true);
|
||||||
|
setInviteError("");
|
||||||
|
try {
|
||||||
|
const [policyResponse, linksResponse] = await Promise.all([
|
||||||
|
getGroupJoinPolicy(activeHousehold.id),
|
||||||
|
getGroupInviteLinks(activeHousehold.id),
|
||||||
|
]);
|
||||||
|
setJoinPolicyValue(policyResponse.data.joinPolicy || "NOT_ACCEPTING");
|
||||||
|
setInviteLinks(linksResponse.data.links || []);
|
||||||
|
} catch (error) {
|
||||||
|
setInviteError(error.response?.data?.error?.message || "Failed to load invite links");
|
||||||
|
} finally {
|
||||||
|
setInviteLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLinkStatus = (link) => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (link.single_use && link.used_at) return "Used";
|
||||||
|
if (link.revoked_at) return "Revoked";
|
||||||
|
if (new Date(link.expires_at).getTime() <= now) return "Expired";
|
||||||
|
return "Active";
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyTextToClipboard = async (text) => {
|
||||||
|
if (!text) return false;
|
||||||
|
|
||||||
|
if (navigator?.clipboard?.writeText) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Fall through to legacy copy fallback.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.setAttribute("readonly", "");
|
||||||
|
textArea.style.position = "fixed";
|
||||||
|
textArea.style.top = "-9999px";
|
||||||
|
textArea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
const copied = document.execCommand("copy");
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
return copied;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyInviteLink = async (token) => {
|
||||||
|
const inviteUrl = `${window.location.origin}/invite/${encodeURIComponent(token)}`;
|
||||||
|
const copied = await copyTextToClipboard(inviteUrl);
|
||||||
|
if (copied) {
|
||||||
|
const tokenLast4 = String(token || "").slice(-4);
|
||||||
|
toast.info("Copied invite link", `Copied invite link ending in ${tokenLast4}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
"Copy invite link failed",
|
||||||
|
"Copy invite link failed: unable to access clipboard. Copy manually."
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateInviteLink = async () => {
|
||||||
|
try {
|
||||||
|
setInviteError("");
|
||||||
|
await createGroupInviteLink(activeHousehold.id, {
|
||||||
|
policy: joinPolicy,
|
||||||
|
singleUse: singleUseMode === "ONE_TIME",
|
||||||
|
ttlDays,
|
||||||
|
});
|
||||||
|
await loadJoinAndInvites();
|
||||||
|
toast.success("Created invite link", `Created invite link (${ttlDays} day TTL)`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to create invite link");
|
||||||
|
setInviteError(message);
|
||||||
|
toast.error("Create invite link failed", `Create invite link failed: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateJoinPolicy = async (value) => {
|
||||||
|
try {
|
||||||
|
setInviteError("");
|
||||||
|
await setGroupJoinPolicy(activeHousehold.id, value);
|
||||||
|
setJoinPolicyValue(value);
|
||||||
|
toast.success("Updated join policy", `Join policy set to ${value}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to update join policy");
|
||||||
|
setInviteError(message);
|
||||||
|
toast.error("Update join policy failed", `Update join policy failed: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeInvite = async (linkId) => {
|
||||||
|
try {
|
||||||
|
setInviteError("");
|
||||||
|
await revokeGroupInviteLink(activeHousehold.id, linkId);
|
||||||
|
await loadJoinAndInvites();
|
||||||
|
toast.success("Revoked invite link", "Revoked invite link");
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to revoke invite link");
|
||||||
|
setInviteError(message);
|
||||||
|
toast.error("Revoke invite link failed", `Revoke invite link failed: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReviveInvite = async (linkId) => {
|
||||||
|
try {
|
||||||
|
setInviteError("");
|
||||||
|
await reviveGroupInviteLink(activeHousehold.id, linkId, ttlDays);
|
||||||
|
await loadJoinAndInvites();
|
||||||
|
toast.success("Revived invite link", `Revived invite link for ${ttlDays} days`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to revive invite link");
|
||||||
|
setInviteError(message);
|
||||||
|
toast.error("Revive invite link failed", `Revive invite link failed: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteInvite = async (linkId) => {
|
||||||
|
if (!confirm("Delete this invite link permanently?")) return;
|
||||||
|
try {
|
||||||
|
setInviteError("");
|
||||||
|
await deleteGroupInviteLink(activeHousehold.id, linkId);
|
||||||
|
await loadJoinAndInvites();
|
||||||
|
toast.success("Deleted invite link", "Deleted invite link");
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to delete invite link");
|
||||||
|
setInviteError(message);
|
||||||
|
toast.error("Delete invite link failed", `Delete invite link failed: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateName = async () => {
|
const handleUpdateName = async () => {
|
||||||
if (!newName.trim() || newName === activeHousehold.name) {
|
if (!newName.trim() || newName === activeHousehold.name) {
|
||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
@ -46,15 +216,13 @@ export default function ManageHousehold() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Updating household:', activeHousehold.id, 'with name:', newName);
|
await updateHousehold(activeHousehold.id, newName);
|
||||||
const response = await updateHousehold(activeHousehold.id, newName);
|
|
||||||
console.log('Update response:', response);
|
|
||||||
await refreshHouseholds();
|
await refreshHouseholds();
|
||||||
|
toast.success("Updated household name", `Updated household name to ${newName.trim()}`);
|
||||||
setEditingName(false);
|
setEditingName(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update household name:", error);
|
const message = getApiErrorMessage(error, "Failed to update household name");
|
||||||
console.error("Error response:", error.response?.data);
|
toast.error("Update household name failed", `Update household name failed: ${message}`);
|
||||||
alert(`Failed to update household name: ${error.response?.data?.error || error.message}`);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -62,46 +230,39 @@ export default function ManageHousehold() {
|
|||||||
if (!confirm("Generate a new invite code? The old code will no longer work.")) return;
|
if (!confirm("Generate a new invite code? The old code will no longer work.")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await refreshInviteCode(activeHousehold.id);
|
await refreshInviteCode(activeHousehold.id);
|
||||||
await refreshHouseholds();
|
await refreshHouseholds();
|
||||||
const refreshedInviteCode = response.data?.household?.invite_code;
|
toast.success("Generated new invite code", "Generated a new invite code");
|
||||||
if (refreshedInviteCode) {
|
|
||||||
alert(`New invite code: ${refreshedInviteCode}`);
|
|
||||||
} else {
|
|
||||||
alert("Invite code refreshed successfully");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
const message = getApiErrorMessage(error, "Failed to refresh invite code");
|
||||||
"Failed to refresh invite code:",
|
toast.error("Refresh invite code failed", `Refresh invite code failed: ${message}`);
|
||||||
error?.response?.data?.error?.message ||
|
|
||||||
error?.response?.data?.message ||
|
|
||||||
error?.message
|
|
||||||
);
|
|
||||||
alert("Failed to refresh invite code");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateRole = async (userId, currentRole) => {
|
const handleUpdateRole = async (memberId, currentRole, memberName) => {
|
||||||
|
if (currentRole === "owner") return;
|
||||||
const newRole = currentRole === "admin" ? "member" : "admin";
|
const newRole = currentRole === "admin" ? "member" : "admin";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateMemberRole(activeHousehold.id, userId, newRole);
|
await updateMemberRole(activeHousehold.id, memberId, newRole);
|
||||||
await loadMembers();
|
await loadMembers();
|
||||||
|
toast.success("Updated member role", `Updated role for ${memberName} to ${newRole}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update role:", error);
|
const message = getApiErrorMessage(error, "Failed to update member role");
|
||||||
alert("Failed to update member role");
|
toast.error("Update member role failed", `Update member role failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMember = async (userId, username) => {
|
const handleRemoveMember = async (memberId, username) => {
|
||||||
if (!confirm(`Remove ${username} from this household?`)) return;
|
if (!confirm(`Remove ${username} from this household?`)) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await removeMember(activeHousehold.id, userId);
|
await removeMember(activeHousehold.id, memberId);
|
||||||
await loadMembers();
|
await loadMembers();
|
||||||
|
toast.success("Removed member", `Removed member ${username}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to remove member:", error);
|
const message = getApiErrorMessage(error, "Failed to remove member");
|
||||||
alert("Failed to remove member");
|
toast.error("Remove member failed", `Remove member failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -110,22 +271,46 @@ export default function ManageHousehold() {
|
|||||||
if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return;
|
if (!confirm("Are you absolutely sure? Type DELETE to confirm.")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const householdName = activeHousehold.name;
|
||||||
await deleteHousehold(activeHousehold.id);
|
await deleteHousehold(activeHousehold.id);
|
||||||
await refreshHouseholds();
|
await refreshHouseholds();
|
||||||
|
toast.success("Deleted household", `Deleted household ${householdName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to delete household:", error);
|
const message = getApiErrorMessage(error, "Failed to delete household");
|
||||||
alert("Failed to delete household");
|
toast.error("Delete household failed", `Delete household failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyInviteCode = () => {
|
const handleLeaveHousehold = async () => {
|
||||||
navigator.clipboard.writeText(activeHousehold.invite_code);
|
if (!activeHousehold?.id) return;
|
||||||
alert("Invite code copied to clipboard!");
|
|
||||||
|
try {
|
||||||
|
const householdName = activeHousehold.name;
|
||||||
|
await removeMember(activeHousehold.id, parseInt(userId, 10));
|
||||||
|
setIsLeaveModalOpen(false);
|
||||||
|
await refreshHouseholds();
|
||||||
|
toast.success("Left household", `Left household ${householdName}`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to leave household");
|
||||||
|
toast.error("Leave household failed", `Leave household failed: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyInviteCode = async () => {
|
||||||
|
const copied = await copyTextToClipboard(activeHousehold.invite_code);
|
||||||
|
if (copied) {
|
||||||
|
toast.info("Copied invite code", "Copied invite code to clipboard");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.error(
|
||||||
|
"Copy invite code failed",
|
||||||
|
"Copy invite code failed: unable to access clipboard. Copy manually."
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="manage-household">
|
<div className="manage-household">
|
||||||
{/* Household Name Section */}
|
|
||||||
<section key="household-name" className="manage-section">
|
<section key="household-name" className="manage-section">
|
||||||
<h2>Household Name</h2>
|
<h2>Household Name</h2>
|
||||||
{editingName ? (
|
{editingName ? (
|
||||||
@ -143,7 +328,7 @@ export default function ManageHousehold() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="name-display">
|
<div className="name-display">
|
||||||
<h3>{activeHousehold.name}</h3>
|
<h3>{activeHousehold.name}</h3>
|
||||||
{isAdmin && (
|
{isManager && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNewName(activeHousehold.name);
|
setNewName(activeHousehold.name);
|
||||||
@ -158,12 +343,11 @@ export default function ManageHousehold() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Invite Code Section */}
|
{isManager && (
|
||||||
{isAdmin && (
|
|
||||||
<section key="invite-code" className="manage-section">
|
<section key="invite-code" className="manage-section">
|
||||||
<h2>Invite Code</h2>
|
<h2>Legacy Invite Code</h2>
|
||||||
<p className="section-description">
|
<p className="section-description">
|
||||||
Share this code with others to invite them to your household.
|
Share this code for legacy join-by-code flows.
|
||||||
</p>
|
</p>
|
||||||
<div className="invite-actions">
|
<div className="invite-actions">
|
||||||
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
|
<button onClick={() => setShowInviteCode(!showInviteCode)} className="btn-secondary">
|
||||||
@ -182,7 +366,85 @@ export default function ManageHousehold() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Members Section */}
|
{isManager && (
|
||||||
|
<section key="join-and-invites" className="manage-section">
|
||||||
|
<h2>Join and Invites</h2>
|
||||||
|
{inviteError && <p className="section-error">{inviteError}</p>}
|
||||||
|
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={joinPolicy}
|
||||||
|
ariaLabel="Join policy options"
|
||||||
|
className="tbg-group manage-household-join-policy-toggle"
|
||||||
|
options={JOIN_POLICY_OPTIONS.map((option) => ({
|
||||||
|
...option,
|
||||||
|
disabled: inviteLoading
|
||||||
|
}))}
|
||||||
|
onChange={handleUpdateJoinPolicy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="invite-controls">
|
||||||
|
<label>
|
||||||
|
TTL
|
||||||
|
<select value={ttlDays} onChange={(e) => setTtlDays(Number(e.target.value))}>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 7].map((day) => (
|
||||||
|
<option key={day} value={day}>{day} day{day > 1 ? "s" : ""}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Usage
|
||||||
|
<select value={singleUseMode} onChange={(e) => setSingleUseMode(e.target.value)}>
|
||||||
|
<option value="UNLIMITED">Unlimited</option>
|
||||||
|
<option value="ONE_TIME">1 use</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="btn-primary" onClick={handleCreateInviteLink} disabled={inviteLoading}>
|
||||||
|
Create link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inviteLoading ? (
|
||||||
|
<p>Loading invite links...</p>
|
||||||
|
) : inviteLinks.length === 0 ? (
|
||||||
|
<p className="section-description">No invite links yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="invite-links-list">
|
||||||
|
{inviteLinks.map((link) => {
|
||||||
|
const status = getLinkStatus(link);
|
||||||
|
const isActive = status === "Active";
|
||||||
|
return (
|
||||||
|
<div key={link.id} className="invite-link-card">
|
||||||
|
<div>
|
||||||
|
<p className="invite-link-token">Token ending in {String(link.token).slice(-4)}</p>
|
||||||
|
<p className="invite-link-meta">
|
||||||
|
Status: <strong>{status}</strong> | Policy: {link.policy} | TTL: until {new Date(link.expires_at).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="invite-link-actions">
|
||||||
|
<button className="btn-secondary btn-small" onClick={() => copyInviteLink(link.token)}>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
{isActive ? (
|
||||||
|
<button className="btn-secondary btn-small" onClick={() => handleRevokeInvite(link.id)}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="btn-secondary btn-small" onClick={() => handleReviveInvite(link.id)}>
|
||||||
|
Revive
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="btn-danger btn-small" onClick={() => handleDeleteInvite(link.id)}>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section key="members" className="manage-section">
|
<section key="members" className="manage-section">
|
||||||
<h2>Members ({members.length})</h2>
|
<h2>Members ({members.length})</h2>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@ -192,22 +454,15 @@ export default function ManageHousehold() {
|
|||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<div key={member.id} className="member-card">
|
<div key={member.id} className="member-card">
|
||||||
<div className="member-info">
|
<div className="member-info">
|
||||||
<span className="member-role">
|
<span className="member-role">{member.role}</span>
|
||||||
{member.role}
|
|
||||||
</span>
|
|
||||||
<span className="member-name">
|
<span className="member-name">
|
||||||
{`
|
{member.username} [{member.id}] {member.id === parseInt(userId, 10) ? "(You)" : ""}
|
||||||
${member.username}
|
|
||||||
[${member.id}]
|
|
||||||
${(member.id === parseInt(userId) ? " (You)" : "")}
|
|
||||||
`}
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && member.id !== parseInt(userId) && (
|
{isManager && member.id !== parseInt(userId, 10) && member.role !== "owner" && (
|
||||||
<div className="member-actions">
|
<div className="member-actions">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateRole(member.id, member.role)}
|
onClick={() => handleUpdateRole(member.id, member.role, member.username)}
|
||||||
className="btn-secondary btn-small"
|
className="btn-secondary btn-small"
|
||||||
>
|
>
|
||||||
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
{member.role === "admin" ? "Make Member" : "Make Admin"}
|
||||||
@ -226,18 +481,34 @@ export default function ManageHousehold() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Danger Zone */}
|
{(isManager || isMemberOnly) && (
|
||||||
{isAdmin && (
|
|
||||||
<section key="danger-zone" className="manage-section danger-zone">
|
<section key="danger-zone" className="manage-section danger-zone">
|
||||||
<h2>Danger Zone</h2>
|
<h2>Danger Zone</h2>
|
||||||
<p className="section-description">
|
<p className="section-description">
|
||||||
Deleting a household is permanent and will delete all lists, items, and history.
|
{isMemberOnly
|
||||||
|
? "Leaving removes your access to this household."
|
||||||
|
: "Deleting a household is permanent and will delete all lists, items, and history."}
|
||||||
</p>
|
</p>
|
||||||
|
{isMemberOnly ? (
|
||||||
|
<button onClick={() => setIsLeaveModalOpen(true)} className="btn-danger">
|
||||||
|
Leave Household
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
<button onClick={handleDeleteHousehold} className="btn-danger">
|
<button onClick={handleDeleteHousehold} className="btn-danger">
|
||||||
Delete Household
|
Delete Household
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmSlideModal
|
||||||
|
isOpen={isLeaveModalOpen}
|
||||||
|
title={`Leave "${activeHousehold?.name || "this household"}"?`}
|
||||||
|
description="Slide all the way to confirm. You will lose access to this household."
|
||||||
|
confirmLabel="Leave Household"
|
||||||
|
onClose={() => setIsLeaveModalOpen(false)}
|
||||||
|
onConfirm={handleLeaveHousehold}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,16 +7,19 @@ import {
|
|||||||
} from "../../api/stores";
|
} from "../../api/stores";
|
||||||
import { HouseholdContext } from "../../context/HouseholdContext";
|
import { HouseholdContext } from "../../context/HouseholdContext";
|
||||||
import { StoreContext } from "../../context/StoreContext";
|
import { StoreContext } from "../../context/StoreContext";
|
||||||
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import "../../styles/components/manage/ManageStores.css";
|
import "../../styles/components/manage/ManageStores.css";
|
||||||
|
|
||||||
export default function ManageStores() {
|
export default function ManageStores() {
|
||||||
const { activeHousehold } = useContext(HouseholdContext);
|
const { activeHousehold } = useContext(HouseholdContext);
|
||||||
const { stores: householdStores, refreshStores } = useContext(StoreContext);
|
const { stores: householdStores, refreshStores } = useContext(StoreContext);
|
||||||
|
const toast = useActionToast();
|
||||||
const [allStores, setAllStores] = useState([]);
|
const [allStores, setAllStores] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showAddStore, setShowAddStore] = useState(false);
|
const [showAddStore, setShowAddStore] = useState(false);
|
||||||
|
|
||||||
const isAdmin = activeHousehold?.role === "admin";
|
const isAdmin = ["owner", "admin"].includes(activeHousehold?.role);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAllStores();
|
loadAllStores();
|
||||||
@ -35,14 +38,17 @@ export default function ManageStores() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAddStore = async (storeId) => {
|
const handleAddStore = async (storeId) => {
|
||||||
|
const storeName = allStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
|
||||||
try {
|
try {
|
||||||
console.log("Adding store with ID:", storeId);
|
console.log("Adding store with ID:", storeId);
|
||||||
await addStoreToHousehold(activeHousehold.id, storeId, false);
|
await addStoreToHousehold(activeHousehold.id, storeId, false);
|
||||||
await refreshStores();
|
await refreshStores();
|
||||||
|
toast.success("Added store", `Added store ${storeName}`);
|
||||||
setShowAddStore(false);
|
setShowAddStore(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add store:", error);
|
console.error("Failed to add store:", error);
|
||||||
alert("Failed to add store");
|
const message = getApiErrorMessage(error, "Failed to add store");
|
||||||
|
toast.error("Add store failed", `Add store failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,19 +58,25 @@ export default function ManageStores() {
|
|||||||
try {
|
try {
|
||||||
await removeStoreFromHousehold(activeHousehold.id, storeId);
|
await removeStoreFromHousehold(activeHousehold.id, storeId);
|
||||||
await refreshStores();
|
await refreshStores();
|
||||||
|
toast.success("Removed store", `Removed store ${storeName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to remove store:", error);
|
console.error("Failed to remove store:", error);
|
||||||
alert("Failed to remove store");
|
const message = getApiErrorMessage(error, "Failed to remove store");
|
||||||
|
toast.error("Remove store failed", `Remove store failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSetDefault = async (storeId) => {
|
const handleSetDefault = async (storeId) => {
|
||||||
|
const storeName =
|
||||||
|
householdStores.find((store) => store.id === storeId)?.name || `store #${storeId}`;
|
||||||
try {
|
try {
|
||||||
await setDefaultStore(activeHousehold.id, storeId);
|
await setDefaultStore(activeHousehold.id, storeId);
|
||||||
await refreshStores();
|
await refreshStores();
|
||||||
|
toast.success("Updated default store", `Default store set to ${storeName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to set default store:", error);
|
console.error("Failed to set default store:", error);
|
||||||
alert("Failed to set default store");
|
const message = getApiErrorMessage(error, "Failed to set default store");
|
||||||
|
toast.error("Set default store failed", `Set default store failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 { useEffect, useState } from "react";
|
||||||
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
import { ITEM_GROUPS, ITEM_TYPES, getItemTypeLabel, getZoneValues } from "../../constants/classifications";
|
||||||
|
import useActionToast from "../../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../../lib/getApiErrorMessage";
|
||||||
import "../../styles/components/EditItemModal.css";
|
import "../../styles/components/EditItemModal.css";
|
||||||
import AddImageModal from "./AddImageModal";
|
import AddImageModal from "./AddImageModal";
|
||||||
|
|
||||||
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) {
|
export default function EditItemModal({ item, onSave, onCancel, onImageUpdate }) {
|
||||||
|
const toast = useActionToast();
|
||||||
const [itemName, setItemName] = useState(item.item_name || "");
|
const [itemName, setItemName] = useState(item.item_name || "");
|
||||||
const [quantity, setQuantity] = useState(item.quantity || 1);
|
const [quantity, setQuantity] = useState(item.quantity || 1);
|
||||||
const [itemType, setItemType] = useState("");
|
const [itemType, setItemType] = useState("");
|
||||||
@ -54,7 +57,8 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
await onSave(item.id, itemName, quantity, classification);
|
await onSave(item.id, itemName, quantity, classification);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save:", error);
|
console.error("Failed to save:", error);
|
||||||
alert("Failed to save changes");
|
const message = getApiErrorMessage(error, "Failed to save changes");
|
||||||
|
toast.error("Save item failed", `Save item failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -63,11 +67,12 @@ export default function EditItemModal({ item, onSave, onCancel, onImageUpdate })
|
|||||||
const handleImageUpload = async (imageFile) => {
|
const handleImageUpload = async (imageFile) => {
|
||||||
if (onImageUpdate) {
|
if (onImageUpdate) {
|
||||||
try {
|
try {
|
||||||
await onImageUpdate(item.id, itemName, quantity, imageFile);
|
await onImageUpdate(item.id, itemName, quantity, imageFile, "edit_modal");
|
||||||
setShowImageModal(false);
|
setShowImageModal(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to upload image:", error);
|
console.error("Failed to upload image:", error);
|
||||||
alert("Failed to upload image");
|
const message = getApiErrorMessage(error, "Failed to upload image");
|
||||||
|
toast.error("Upload image failed", `Upload image failed: ${message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
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 { getAllUsers, updateRole } from "../api/users";
|
||||||
import StoreManagement from "../components/admin/StoreManagement";
|
import StoreManagement from "../components/admin/StoreManagement";
|
||||||
import UserRoleCard from "../components/common/UserRoleCard";
|
import UserRoleCard from "../components/common/UserRoleCard";
|
||||||
|
import useActionToast from "../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||||
import "../styles/UserRoleCard.css";
|
import "../styles/UserRoleCard.css";
|
||||||
import "../styles/pages/AdminPanel.css";
|
import "../styles/pages/AdminPanel.css";
|
||||||
|
|
||||||
export default function AdminPanel() {
|
export default function AdminPanel() {
|
||||||
|
const toast = useActionToast();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [activeTab, setActiveTab] = useState("users");
|
const [activeTab, setActiveTab] = useState("users");
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
const allUsers = await getAllUsers();
|
const allUsers = await getAllUsers();
|
||||||
console.log("Users found:", users);
|
|
||||||
setUsers(allUsers.data);
|
setUsers(allUsers.data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to load users");
|
||||||
|
toast.error("Load users failed", `Load users failed: ${message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -20,10 +27,22 @@ export default function AdminPanel() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const changeRole = async (id, role) => {
|
const changeRole = async (id, role) => {
|
||||||
|
const selectedUser = users.find((user) => user.id === id);
|
||||||
|
const username = selectedUser?.username || `user #${id}`;
|
||||||
|
|
||||||
|
try {
|
||||||
const updated = await updateRole(id, role);
|
const updated = await updateRole(id, role);
|
||||||
if (updated.status !== 200) return;
|
if (updated.status !== 200) {
|
||||||
loadUsers();
|
toast.error("Update role failed", "Update role failed: unexpected response");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
toast.success("Updated user role", `Updated role for ${username} to ${role}`);
|
||||||
|
loadUsers();
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to update user role");
|
||||||
|
toast.error("Update role failed", `Update role failed: ${message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-panel-body">
|
<div className="admin-panel-body">
|
||||||
@ -62,5 +81,5 @@ export default function AdminPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@ -26,7 +26,9 @@ import { HouseholdContext } from "../context/HouseholdContext";
|
|||||||
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
|
import { IMAGE_UPLOAD_SUCCESS_EVENT } from "../context/UploadQueueContext";
|
||||||
import { SettingsContext } from "../context/SettingsContext";
|
import { SettingsContext } from "../context/SettingsContext";
|
||||||
import { StoreContext } from "../context/StoreContext";
|
import { StoreContext } from "../context/StoreContext";
|
||||||
|
import useActionToast from "../hooks/useActionToast";
|
||||||
import useUploadQueue from "../hooks/useUploadQueue";
|
import useUploadQueue from "../hooks/useUploadQueue";
|
||||||
|
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||||
import "../styles/pages/GroceryList.css";
|
import "../styles/pages/GroceryList.css";
|
||||||
import { findSimilarItems } from "../utils/stringSimilarity";
|
import { findSimilarItems } from "../utils/stringSimilarity";
|
||||||
|
|
||||||
@ -37,6 +39,7 @@ export default function GroceryList() {
|
|||||||
const { activeHousehold } = useContext(HouseholdContext);
|
const { activeHousehold } = useContext(HouseholdContext);
|
||||||
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
|
const { activeStore, stores, loading: storeLoading } = useContext(StoreContext);
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
|
const toast = useActionToast();
|
||||||
const { enqueueImageUpload } = useUploadQueue();
|
const { enqueueImageUpload } = useUploadQueue();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
@ -255,6 +258,7 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
// === Item Addition Handlers ===
|
// === Item Addition Handlers ===
|
||||||
const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => {
|
const handleAdd = useCallback(async (itemName, quantity, addedForUserId = null) => {
|
||||||
|
try {
|
||||||
const normalizedItemName = itemName.trim().toLowerCase();
|
const normalizedItemName = itemName.trim().toLowerCase();
|
||||||
if (!normalizedItemName) return;
|
if (!normalizedItemName) return;
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
@ -289,6 +293,9 @@ export default function GroceryList() {
|
|||||||
skipLookup: shouldSkipLookup,
|
skipLookup: shouldSkipLookup,
|
||||||
addedForUserId
|
addedForUserId
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to process add item flow:", error);
|
||||||
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
|
}, [activeHousehold?.id, activeStore?.id, items, recentlyBoughtItems, buttonText]);
|
||||||
|
|
||||||
|
|
||||||
@ -325,6 +332,7 @@ export default function GroceryList() {
|
|||||||
});
|
});
|
||||||
setShowConfirmAddExisting(true);
|
setShowConfirmAddExisting(true);
|
||||||
} else if (existingItem) {
|
} else if (existingItem) {
|
||||||
|
try {
|
||||||
await addItem(
|
await addItem(
|
||||||
activeHousehold.id,
|
activeHousehold.id,
|
||||||
activeStore.id,
|
activeStore.id,
|
||||||
@ -336,15 +344,21 @@ export default function GroceryList() {
|
|||||||
);
|
);
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
toast.success("Added item", `Added item ${itemName}`);
|
||||||
|
|
||||||
// Reload lists to reflect the changes
|
// Reload lists to reflect the changes
|
||||||
await loadItems();
|
await loadItems();
|
||||||
await loadRecentlyBought();
|
await loadRecentlyBought();
|
||||||
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to add item");
|
||||||
|
toast.error("Add item failed", `Add item failed: ${message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setPendingItem({ itemName, quantity, addedForUserId });
|
setPendingItem({ itemName, quantity, addedForUserId });
|
||||||
setShowAddDetailsModal(true);
|
setShowAddDetailsModal(true);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, loadItems]);
|
}, [activeHousehold?.id, activeStore?.id, loadItems, loadRecentlyBought, toast]);
|
||||||
|
|
||||||
|
|
||||||
// === Similar Item Modal Handlers ===
|
// === Similar Item Modal Handlers ===
|
||||||
@ -407,11 +421,14 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
setSuggestions([]);
|
setSuggestions([]);
|
||||||
setButtonText("Add Item");
|
setButtonText("Add Item");
|
||||||
|
toast.success("Updated item quantity", `Updated item ${itemName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update item:", error);
|
console.error("Failed to update item:", error);
|
||||||
|
const message = getApiErrorMessage(error, "Failed to update item");
|
||||||
|
toast.error("Update item failed", `Update item failed: ${message}`);
|
||||||
await loadItems();
|
await loadItems();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [activeHousehold?.id, activeStore?.id, confirmAddExistingData, loadItems, toast]);
|
||||||
|
|
||||||
|
|
||||||
// === Add Details Modal Handlers ===
|
// === Add Details Modal Handlers ===
|
||||||
@ -434,6 +451,7 @@ export default function GroceryList() {
|
|||||||
if (classification) {
|
if (classification) {
|
||||||
// Apply classification if provided
|
// Apply classification if provided
|
||||||
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification);
|
await updateItemWithClassification(activeHousehold.id, activeStore.id, pendingItem.itemName, pendingItem.quantity, classification);
|
||||||
|
toast.success("Updated item classification", `Updated classification for ${pendingItem.itemName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the newly added item
|
// Fetch the newly added item
|
||||||
@ -448,6 +466,7 @@ export default function GroceryList() {
|
|||||||
// Add to state
|
// Add to state
|
||||||
if (newItem) {
|
if (newItem) {
|
||||||
setItems(prevItems => [...prevItems, newItem]);
|
setItems(prevItems => [...prevItems, newItem]);
|
||||||
|
toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`);
|
||||||
|
|
||||||
if (imageFile) {
|
if (imageFile) {
|
||||||
enqueueImageUpload({
|
enqueueImageUpload({
|
||||||
@ -462,13 +481,15 @@ export default function GroceryList() {
|
|||||||
source: "add_details",
|
source: "add_details",
|
||||||
localItemId: newItem.id,
|
localItemId: newItem.id,
|
||||||
});
|
});
|
||||||
|
toast.info("Queued image upload", `Queued image upload for ${newItem.item_name || pendingItem.itemName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
const message = getApiErrorMessage(error, "Failed to add item");
|
||||||
|
toast.error("Add item failed", `Add item failed: ${message}`);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload]);
|
}, [activeHousehold?.id, activeStore?.id, pendingItem, enqueueImageUpload, toast]);
|
||||||
|
|
||||||
const handleAddDetailsSkip = useCallback(async () => {
|
const handleAddDetailsSkip = useCallback(async () => {
|
||||||
if (!pendingItem) return;
|
if (!pendingItem) return;
|
||||||
@ -496,12 +517,14 @@ export default function GroceryList() {
|
|||||||
|
|
||||||
if (newItem) {
|
if (newItem) {
|
||||||
setItems(prevItems => [...prevItems, newItem]);
|
setItems(prevItems => [...prevItems, newItem]);
|
||||||
|
toast.success("Added item", `Added item ${newItem.item_name || pendingItem.itemName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add item:", error);
|
console.error("Failed to add item:", error);
|
||||||
alert("Failed to add item. Please try again.");
|
const message = getApiErrorMessage(error, "Failed to add item");
|
||||||
|
toast.error("Add item failed", `Add item failed: ${message}`);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, pendingItem]);
|
}, [activeHousehold?.id, activeStore?.id, pendingItem, toast]);
|
||||||
|
|
||||||
|
|
||||||
const handleAddDetailsCancel = useCallback(() => {
|
const handleAddDetailsCancel = useCallback(() => {
|
||||||
@ -519,23 +542,29 @@ export default function GroceryList() {
|
|||||||
const item = items.find(i => i.id === id);
|
const item = items.find(i => i.id === id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
|
try {
|
||||||
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
await markBought(activeHousehold.id, activeStore.id, item.item_name, quantity, true);
|
||||||
|
|
||||||
// If buying full quantity, remove from list
|
// If buying full quantity, remove from list
|
||||||
if (quantity >= item.quantity) {
|
if (quantity >= item.quantity) {
|
||||||
setItems(prevItems => prevItems.filter(item => item.id !== id));
|
setItems(prevItems => prevItems.filter((existingItem) => existingItem.id !== id));
|
||||||
} else {
|
} else {
|
||||||
// If partial, fetch updated item
|
// If partial, fetch updated item
|
||||||
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
const response = await getItemByName(activeHousehold.id, activeStore.id, item.item_name);
|
||||||
const updatedItem = response.data;
|
const updatedItem = response.data;
|
||||||
|
|
||||||
setItems(prevItems =>
|
setItems((prevItems) =>
|
||||||
prevItems.map(i => i.id === id ? updatedItem : i)
|
prevItems.map((existingItem) => (existingItem.id === id ? updatedItem : existingItem))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toast.success("Marked item bought", `Marked item ${item.item_name} as bought`);
|
||||||
loadRecentlyBought();
|
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") => {
|
const handleImageAdded = useCallback(async (id, itemName, quantity, imageFile, source = "add_image_modal") => {
|
||||||
if (!activeHousehold?.id || !activeStore?.id) return;
|
if (!activeHousehold?.id || !activeStore?.id) return;
|
||||||
@ -554,11 +583,13 @@ export default function GroceryList() {
|
|||||||
source,
|
source,
|
||||||
localItemId: id,
|
localItemId: id,
|
||||||
});
|
});
|
||||||
|
toast.info("Queued image upload", `Queued image upload for ${itemName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to add image:", error);
|
console.error("Failed to add image:", error);
|
||||||
alert("Failed to add image. Please try again.");
|
const message = getApiErrorMessage(error, "Failed to add image");
|
||||||
|
toast.error("Add image failed", `Add image failed: ${message}`);
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id, enqueueImageUpload]);
|
}, [activeHousehold?.id, activeStore?.id, enqueueImageUpload, toast]);
|
||||||
|
|
||||||
|
|
||||||
const handleLongPress = useCallback(async (item) => {
|
const handleLongPress = useCallback(async (item) => {
|
||||||
@ -605,11 +636,14 @@ export default function GroceryList() {
|
|||||||
item.id === id ? { ...item, ...updatedItem } : item
|
item.id === id ? { ...item, ...updatedItem } : item
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
toast.success("Updated item", `Updated item ${itemName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update item:", error);
|
console.error("Failed to update item:", error);
|
||||||
|
const message = getApiErrorMessage(error, "Failed to update item");
|
||||||
|
toast.error("Update item failed", `Update item failed: ${message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [activeHousehold?.id, activeStore?.id]);
|
}, [activeHousehold?.id, activeStore?.id, toast]);
|
||||||
|
|
||||||
|
|
||||||
const handleEditCancel = useCallback(() => {
|
const handleEditCancel = useCallback(() => {
|
||||||
|
|||||||
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 { useContext, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import { loginRequest } from "../api/auth";
|
import { loginRequest } from "../api/auth";
|
||||||
import ErrorMessage from "../components/common/ErrorMessage";
|
import ErrorMessage from "../components/common/ErrorMessage";
|
||||||
import FormInput from "../components/common/FormInput";
|
import FormInput from "../components/common/FormInput";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
|
import useActionToast from "../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||||
import "../styles/pages/Login.css";
|
import "../styles/pages/Login.css";
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { login } = useContext(AuthContext);
|
const { login } = useContext(AuthContext);
|
||||||
|
const toast = useActionToast();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
@ -20,9 +24,12 @@ export default function Login() {
|
|||||||
try {
|
try {
|
||||||
const data = await loginRequest(username, password);
|
const data = await loginRequest(username, password);
|
||||||
login(data);
|
login(data);
|
||||||
window.location.href = "/";
|
toast.success("Logged in", `Welcome back ${data?.username || username}`);
|
||||||
|
navigate("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.message || "Login failed");
|
const message = getApiErrorMessage(err, "Login failed");
|
||||||
|
setError(message);
|
||||||
|
toast.error("Login failed", `Login failed: ${message}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,11 +5,14 @@ import { checkIfUserExists } from "../api/users";
|
|||||||
import ErrorMessage from "../components/common/ErrorMessage";
|
import ErrorMessage from "../components/common/ErrorMessage";
|
||||||
import FormInput from "../components/common/FormInput";
|
import FormInput from "../components/common/FormInput";
|
||||||
import { AuthContext } from "../context/AuthContext";
|
import { AuthContext } from "../context/AuthContext";
|
||||||
|
import useActionToast from "../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||||
import "../styles/pages/Register.css";
|
import "../styles/pages/Register.css";
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useContext(AuthContext);
|
const { login } = useContext(AuthContext);
|
||||||
|
const toast = useActionToast();
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@ -47,12 +50,16 @@ export default function Register() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await registerRequest(username, password, name);
|
await registerRequest(username, password, name);
|
||||||
|
toast.success("Registered account", `Created account for ${username}`);
|
||||||
const data = await loginRequest(username, password);
|
const data = await loginRequest(username, password);
|
||||||
login(data);
|
login(data);
|
||||||
|
toast.success("Logged in", `Welcome ${data?.username || username}`);
|
||||||
setSuccess("Account created! Redirecting to the grocery list...");
|
setSuccess("Account created! Redirecting to the grocery list...");
|
||||||
setTimeout(() => navigate("/"), 2000);
|
setTimeout(() => navigate("/"), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.response?.data?.message || "Registration failed");
|
const message = getApiErrorMessage(err, "Registration failed");
|
||||||
|
setError(message);
|
||||||
|
toast.error("Registration failed", `Registration failed: ${message}`);
|
||||||
setTimeout(() => setError(""), 1000);
|
setTimeout(() => setError(""), 1000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { useContext, useEffect, useRef, useState } from "react";
|
import { useContext, useEffect, useRef, useState } from "react";
|
||||||
import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users";
|
import { changePassword, getCurrentUser, updateCurrentUser } from "../api/users";
|
||||||
import { SettingsContext } from "../context/SettingsContext";
|
import { SettingsContext } from "../context/SettingsContext";
|
||||||
|
import useActionToast from "../hooks/useActionToast";
|
||||||
|
import getApiErrorMessage from "../lib/getApiErrorMessage";
|
||||||
import "../styles/pages/Settings.css";
|
import "../styles/pages/Settings.css";
|
||||||
|
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { settings, updateSettings, resetSettings } = useContext(SettingsContext);
|
const { settings, updateSettings, resetSettings } = useContext(SettingsContext);
|
||||||
|
const toast = useActionToast();
|
||||||
const [activeTab, setActiveTab] = useState("appearance");
|
const [activeTab, setActiveTab] = useState("appearance");
|
||||||
const tabsRef = useRef(null);
|
const tabsRef = useRef(null);
|
||||||
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
const [showLeftArrow, setShowLeftArrow] = useState(false);
|
||||||
@ -75,11 +78,14 @@ export default function Settings() {
|
|||||||
try {
|
try {
|
||||||
await updateCurrentUser(displayName);
|
await updateCurrentUser(displayName);
|
||||||
setAccountMessage({ type: "success", text: "Display name updated successfully!" });
|
setAccountMessage({ type: "success", text: "Display name updated successfully!" });
|
||||||
|
toast.success("Updated display name", `Updated display name to ${displayName}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to update display name");
|
||||||
setAccountMessage({
|
setAccountMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: error.response?.data?.error || "Failed to update display name"
|
text: message
|
||||||
});
|
});
|
||||||
|
toast.error("Update display name failed", `Update display name failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingProfile(false);
|
setLoadingProfile(false);
|
||||||
}
|
}
|
||||||
@ -105,14 +111,17 @@ export default function Settings() {
|
|||||||
try {
|
try {
|
||||||
await changePassword(currentPassword, newPassword);
|
await changePassword(currentPassword, newPassword);
|
||||||
setAccountMessage({ type: "success", text: "Password changed successfully!" });
|
setAccountMessage({ type: "success", text: "Password changed successfully!" });
|
||||||
|
toast.success("Changed password", "Changed password successfully");
|
||||||
setCurrentPassword("");
|
setCurrentPassword("");
|
||||||
setNewPassword("");
|
setNewPassword("");
|
||||||
setConfirmPassword("");
|
setConfirmPassword("");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = getApiErrorMessage(error, "Failed to change password");
|
||||||
setAccountMessage({
|
setAccountMessage({
|
||||||
type: "error",
|
type: "error",
|
||||||
text: error.response?.data?.error || "Failed to change password"
|
text: message
|
||||||
});
|
});
|
||||||
|
toast.error("Change password failed", `Change password failed: ${message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingPassword(false);
|
setLoadingPassword(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
.add-item-form-assignee-toggle {
|
.add-item-form-assignee-toggle {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
width: 134px;
|
width: 112px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,7 +201,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-assignee-toggle {
|
.add-item-form-assignee-toggle {
|
||||||
width: 120px;
|
width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-item-form-quantity-control {
|
.add-item-form-quantity-control {
|
||||||
|
|||||||
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 {
|
.store-tabs-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 0.5rem 1rem 0;
|
padding: 0.5rem 1rem 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tabs-container::-webkit-scrollbar {
|
.store-tabs-container::-webkit-scrollbar {
|
||||||
@ -23,6 +25,7 @@
|
|||||||
.store-tab {
|
.store-tab {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@ -34,6 +37,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.store-tab:hover {
|
.store-tab:hover {
|
||||||
|
|||||||
@ -56,6 +56,12 @@
|
|||||||
font-weight: var(--font-weight-semibold);
|
font-weight: var(--font-weight-semibold);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tbg-button.tbg-size-xxs {
|
||||||
|
padding: 0.25rem 0.38rem;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
.tbg-button.is-active {
|
.tbg-button.is-active {
|
||||||
color: var(--color-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
|||||||
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;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-error {
|
||||||
|
color: var(--danger);
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-household-join-policy-toggle {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.8rem;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-controls label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-controls select {
|
||||||
|
min-width: 120px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-links-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-card {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--background);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-token,
|
||||||
|
.invite-link-meta {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-token {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-meta {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* Members Section */
|
/* Members Section */
|
||||||
.members-list {
|
.members-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -244,4 +316,23 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-controls label,
|
||||||
|
.invite-controls select,
|
||||||
|
.invite-controls button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-actions {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-link-actions button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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": {
|
"scripts": {
|
||||||
"db:migrate": "node scripts/db-migrate.js",
|
"db:migrate": "node scripts/db-migrate.js",
|
||||||
"db:migrate:status": "node scripts/db-migrate-status.js",
|
"db:migrate:status": "node scripts/db-migrate-status.js",
|
||||||
"db:migrate:verify": "node scripts/db-migrate-verify.js"
|
"db:migrate:verify": "node scripts/db-migrate-verify.js",
|
||||||
|
"db:migrate:new": "node scripts/db-migrate-new.js",
|
||||||
|
"db:migrate:stale": "node scripts/db-stale-sql-tracker.js --write",
|
||||||
|
"db:migrate:stale:check": "node scripts/db-stale-sql-tracker.js --fail-on-stale",
|
||||||
|
"test": "jest --runInBand",
|
||||||
|
"test:e2e": "npm --prefix frontend run test:e2e --",
|
||||||
|
"test:e2e:headed": "npm --prefix frontend run test:e2e:headed --",
|
||||||
|
"test:e2e:ui": "npm --prefix frontend run test:e2e:ui --"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
|
|||||||
@ -5,5 +5,11 @@ This directory is the canonical location for SQL migrations.
|
|||||||
- Use `npm run db:migrate` to apply pending migrations.
|
- Use `npm run db:migrate` to apply pending migrations.
|
||||||
- Use `npm run db:migrate:status` to view applied/pending migrations.
|
- Use `npm run db:migrate:status` to view applied/pending migrations.
|
||||||
- Use `npm run db:migrate:verify` to fail when pending migrations exist.
|
- Use `npm run db:migrate:verify` to fail when pending migrations exist.
|
||||||
|
- Use `npm run db:migrate:new -- <migration-name>` to create a new migration file.
|
||||||
|
|
||||||
Do not place new canonical migrations under `backend/migrations`.
|
Do not place new canonical migrations under `backend/migrations`.
|
||||||
|
|
||||||
|
## Stale baseline
|
||||||
|
- `stale-files.json` lists canonical SQL files intentionally treated as stale.
|
||||||
|
- Files listed there are skipped by migration commands by default.
|
||||||
|
- Add only genuinely new migration files when schema changes are required.
|
||||||
|
|||||||
@ -1,20 +1,8 @@
|
|||||||
# Database Migration: Add Image Support
|
|
||||||
|
|
||||||
Run these SQL commands on your PostgreSQL database:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Add image columns to grocery_list table
|
-- Add image columns to grocery_list table
|
||||||
ALTER TABLE grocery_list
|
ALTER TABLE grocery_list
|
||||||
ADD COLUMN item_image BYTEA,
|
ADD COLUMN IF NOT EXISTS item_image BYTEA,
|
||||||
ADD COLUMN image_mime_type VARCHAR(50);
|
ADD COLUMN IF NOT EXISTS image_mime_type VARCHAR(50);
|
||||||
|
|
||||||
-- Optional: Add index for faster queries when filtering by items with images
|
-- Index to speed up queries that filter by rows with images.
|
||||||
CREATE INDEX idx_grocery_list_has_image ON grocery_list ((item_image IS NOT NULL));
|
CREATE INDEX IF NOT EXISTS idx_grocery_list_has_image
|
||||||
```
|
ON grocery_list ((item_image IS NOT NULL));
|
||||||
|
|
||||||
## To Verify:
|
|
||||||
```sql
|
|
||||||
\d grocery_list
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see the new columns `item_image` and `image_mime_type`.
|
|
||||||
|
|||||||
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",
|
"db",
|
||||||
"migrations"
|
"migrations"
|
||||||
);
|
);
|
||||||
|
const staleConfigPath = path.join(migrationsDir, "stale-files.json");
|
||||||
|
|
||||||
|
function readStaleConfigFile() {
|
||||||
|
if (!fs.existsSync(staleConfigPath)) {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(staleConfigPath, "utf8");
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Invalid JSON in ${staleConfigPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = Array.isArray(parsed?.stale_files) ? parsed.stale_files : [];
|
||||||
|
return new Set(
|
||||||
|
values
|
||||||
|
.map((value) => String(value || "").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSkippedMigrations() {
|
||||||
|
const includeStale = String(process.env.DB_MIGRATE_INCLUDE_STALE || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const skipFromConfig =
|
||||||
|
includeStale === "1" || includeStale === "true" || includeStale === "yes"
|
||||||
|
? new Set()
|
||||||
|
: readStaleConfigFile();
|
||||||
|
|
||||||
|
const raw = process.env.DB_MIGRATE_SKIP_FILES || "";
|
||||||
|
const skipFromEnv = new Set(
|
||||||
|
raw
|
||||||
|
.split(",")
|
||||||
|
.map((value) => value.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Set([...skipFromConfig, ...skipFromEnv]);
|
||||||
|
}
|
||||||
|
|
||||||
function ensureDatabaseUrl() {
|
function ensureDatabaseUrl() {
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
@ -35,9 +77,11 @@ function ensureMigrationsDir() {
|
|||||||
|
|
||||||
function getMigrationFiles() {
|
function getMigrationFiles() {
|
||||||
ensureMigrationsDir();
|
ensureMigrationsDir();
|
||||||
|
const skipped = getSkippedMigrations();
|
||||||
return fs
|
return fs
|
||||||
.readdirSync(migrationsDir)
|
.readdirSync(migrationsDir)
|
||||||
.filter((file) => file.endsWith(".sql"))
|
.filter((file) => file.endsWith(".sql"))
|
||||||
|
.filter((file) => !skipped.has(file))
|
||||||
.sort((a, b) => a.localeCompare(b));
|
.sort((a, b) => a.localeCompare(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,5 +148,6 @@ module.exports = {
|
|||||||
ensureSchemaMigrationsTable,
|
ensureSchemaMigrationsTable,
|
||||||
getAppliedMigrations,
|
getAppliedMigrations,
|
||||||
getMigrationFiles,
|
getMigrationFiles,
|
||||||
|
getSkippedMigrations,
|
||||||
migrationsDir,
|
migrationsDir,
|
||||||
};
|
};
|
||||||
|
|||||||
69
scripts/db-migrate-new.js
Normal file
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const migrateDisabled = String(process.env.DB_MIGRATE_DISABLE || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
if (migrateDisabled === "1" || migrateDisabled === "true" || migrateDisabled === "yes") {
|
||||||
|
console.log("DB migrations are disabled by DB_MIGRATE_DISABLE. Skipping.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const databaseUrl = ensureDatabaseUrl();
|
const databaseUrl = ensureDatabaseUrl();
|
||||||
ensurePsql();
|
ensurePsql();
|
||||||
ensureSchemaMigrationsTable(databaseUrl);
|
ensureSchemaMigrationsTable(databaseUrl);
|
||||||
|
|||||||
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