diff --git a/README.md b/README.md index 02441b82..dd9c742c 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ before making a package-level licence statement. [![Latest release](https://img.shields.io/github/v/release/Posnic/POS?include_prereleases&label=latest&color=blue)](https://github.com/Posnic/POS/releases/latest) [![Tests](https://img.shields.io/badge/tests-9%2C000%2B%20passing-brightgreen)](docs/DEVELOPMENT.md#running-the-tests) [![Coverage](https://img.shields.io/badge/coverage-69%25%20statements-yellow)](docs/DEVELOPMENT.md#running-the-tests) -[![API](https://img.shields.io/badge/REST%20API-869%20endpoints-blue)](docs/API.md) +[![API](https://img.shields.io/badge/REST%20API-870%20endpoints-blue)](docs/API.md) [![Source licence](https://img.shields.io/badge/source%20licence-AGPL--3.0-blue)](LICENSE) [![Package notices](https://img.shields.io/badge/package%20notices-component%20licences-informational)](THIRD-PARTY-NOTICES.md) [![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](https://github.com/Posnic/POS/releases/latest) @@ -235,7 +235,7 @@ the free edition worse. This is written down in [GOVERNANCE.md](docs/GOVERNANCE. | [Contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md) | Local setup, test commands, issue map, PR flow and safety rules | | [Developer guide](docs/DEVELOPMENT.md) | Setup, tests, conventions, good first issues | | [Architecture](docs/ARCHITECTURE.md) | How it fits together, and the parts that bite | -| [REST API](docs/API.md) | 869 endpoints, generated from the routes | +| [REST API](docs/API.md) | 870 endpoints, generated from the routes | | [Hardware](docs/HARDWARE_MATRIX.md) | Printers, scanners, drawers, scales — and how far each claim is checked | | [India e-invoicing](docs/INDIA_EINVOICING_DESIGN.md) | Research, readiness inventory and design for GST e-invoicing as an optional feature; no live IRP submission is built | | [India GST return gaps](docs/INDIA_GST_RETURNS_GAPS.md) | What the GST reports compute today, measured against GSTR-1, 3B and 9 | diff --git a/api/app.js b/api/app.js index a9485ed2..6edc46cc 100644 --- a/api/app.js +++ b/api/app.js @@ -1506,16 +1506,23 @@ app.post(['/push/test', '/api/push/test'], sseProtect, async (req, res) => { try { const result = req.db && req.user - ? await pushInfra.sendToUser(req.db, req.user._id, { - title: 'Posnic', - body: 'Notifications are working on this device.', - url: '/dashboard.html#/dashboard', - }) + ? await pushInfra.sendToUser( + req.db, + req.user._id, + { + title: 'Posnic', + body: 'Notifications are working on this device.', + url: '/dashboard.html#/dashboard', + }, + typeof req.body?.endpoint === 'string' ? req.body.endpoint : undefined + ) : { sent: 0 }; res.json({ - type: 'success', + type: result.sent ? 'success' : 'error', data: result, - message: result.sent ? 'Sent' : 'No subscriptions on this device yet', + message: result.sent + ? 'Sent' + : 'Notification could not be delivered. Check device registration and try again.', }); } catch (e) { res.status(500).json({ type: 'error', message: 'Send failed' }); diff --git a/api/src/realtime/push.js b/api/src/realtime/push.js index f61ad682..a420b6c3 100644 --- a/api/src/realtime/push.js +++ b/api/src/realtime/push.js @@ -82,7 +82,7 @@ async function subscribe(db, userId, subscription) { * Deliver to every device of one user. 404/410 mean the browser revoked the * subscription; those rows are pruned so the store never accumulates ghosts. */ -async function sendToUser(db, userId, payload) { +async function sendToUser(db, userId, payload, endpoint) { const wp = webpush(); if (!wp || !db) return { sent: 0 }; const vapid = await getVapid(db); @@ -90,7 +90,7 @@ async function sendToUser(db, userId, payload) { const rows = await db .collection(COLLECTION) - .find({ user_id: String(userId) }) + .find({ user_id: String(userId), ...(endpoint ? { endpoint } : {}) }) .toArray(); let sent = 0; for (const row of rows) { diff --git a/api/src/routes/items.routes.js b/api/src/routes/items.routes.js index b796816c..ba029785 100644 --- a/api/src/routes/items.routes.js +++ b/api/src/routes/items.routes.js @@ -98,6 +98,7 @@ const expiryReminders = require('../services/item-expiry-reminders'); router.get('/expiryReminders/preference', preferenceHandler(expiryReminders.read)); router.put('/expiryReminders/preference', preferenceHandler(expiryReminders.save)); router.get('/expiryReminders', preferenceHandler(expiryReminders.list)); +router.get('/expiryReport', preferenceHandler(expiryReminders.report)); /* * A dish that has run out, said by whoever found out first. diff --git a/api/src/services/item-expiry-reminders.js b/api/src/services/item-expiry-reminders.js index e89da211..39bf654c 100644 --- a/api/src/services/item-expiry-reminders.js +++ b/api/src/services/item-expiry-reminders.js @@ -35,13 +35,22 @@ function windowFor(branch, days, now) { const today = moment(now).tz(timezone).format('YYYY-MM-DD'); return { today, through: moment.utc(today).add(days, 'days').format('YYYY-MM-DD'), timezone }; } -async function list(req, now = new Date()) { +async function list(req, now = new Date(), report = false) { const c = await scoped(req), settings = preference(c.branch); const page = Number(req.query?.page ?? 1); if (!Number.isSafeInteger(page) || page < 1 || page > 100000) fail('Choose a valid page.'); - const range = windowFor(c.branch, settings.days, now); - if (!settings.enabled) + const days = report ? Number(req.query?.days ?? 30) : settings.days; + if (!Number.isInteger(days) || days < 0 || days > 365) + fail('Choose a period between 0 and 365 days.'); + const status = report ? req.query?.status || 'all' : 'all'; + const sort = report ? req.query?.sort || 'asc' : 'asc'; + if (!['all', 'expired', 'upcoming'].includes(status) || !['asc', 'desc'].includes(sort)) + fail('Invalid report filter.'); + const search = report ? String(req.query?.search || '').trim() : ''; + if (search.length > 100) fail('Search must be at most 100 characters.'); + const range = windowFor(c.branch, days, now); + if (!report && !settings.enabled) return { ...settings, ...range, @@ -57,6 +66,9 @@ async function list(req, now = new Date()) { $or: [{ branch_id: c.branchId }, { 'branch_access.branch_id': c.branchId }], item_status: { $ne: 'instant' }, available_quantity: { $gt: 0 }, + ...(search + ? { name: { $regex: search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' } } + : {}), }, }, // Item expiry is a calendar date; never shift it to the browser timezone. @@ -71,7 +83,19 @@ async function list(req, now = new Date()) { }, }, }, - { $match: { expiryDay: { $regex: '^\\d{4}-\\d{2}-\\d{2}$', $lte: range.through } } }, + { + $match: { + expiryDay: { + $regex: '^\\d{4}-\\d{2}-\\d{2}$', + $lte: range.through, + ...(status === 'expired' + ? { $lt: range.today } + : status === 'upcoming' + ? { $gte: range.today } + : {}), + }, + }, + }, { $set: { expiryParsed: { @@ -90,7 +114,7 @@ async function list(req, now = new Date()) { }, }, }, - { $sort: { expiryDay: 1, _id: 1 } }, + { $sort: { expiryDay: sort === 'desc' ? -1 : 1, _id: 1 } }, { $facet: { count: [{ $count: 'total' }], @@ -122,4 +146,11 @@ async function list(req, now = new Date()) { })), }; } -module.exports = { read, save, list, preference, windowFor }; +module.exports = { + read, + save, + list, + report: (req, now) => list(req, now, true), + preference, + windowFor, +}; diff --git a/api/tests/item-expiry-reminders.integration.cjs b/api/tests/item-expiry-reminders.integration.cjs index a964fbfb..fbbdc5a3 100644 --- a/api/tests/item-expiry-reminders.integration.cjs +++ b/api/tests/item-expiry-reminders.integration.cjs @@ -56,3 +56,17 @@ test('only current shop stocked products in date window; invalid dates ignored; assert.equal(new Set([...first.rows, ...next.rows].map(row => row.id)).size, 30); req.body.enabled = false; await service.save(req); assert.equal((await service.list(req, now)).total, 0); }); +test('report works with reminders off, filters literal search, sorts and rejects invalid filters', async () => { + const req = await fixture(); + const base = { license:req.tenantContext.licenseId,branch_id:req.tenantContext.branchId,available_quantity:3 }; + await db.collection('items').insertMany([{...base,name:'Milk (small)',items_expiry_date:'2026-10-07'}, {...base,name:'Milk large',items_expiry_date:'2026-10-12'}, {...base,name:'Later',items_expiry_date:'2026-11-12'}]); + const now = new Date('2026-10-08T12:00:00Z'); + assert.equal((await service.list(req,now)).total,0); + assert.equal((await service.report(req,now)).total,2); + req.query={sort:'desc'};assert.equal((await service.report(req,now)).rows[0].name,'Milk large'); + req.query={status:'expired'};assert.equal((await service.report(req,now)).total,1); + req.query={status:'upcoming'};assert.equal((await service.report(req,now)).rows[0].name,'Milk large'); + req.query={search:'(small)'};assert.equal((await service.report(req,now)).total,1); + req.query={search:'.*'};assert.equal((await service.report(req,now)).total,0); + for(const query of [{days:366},{days:-1},{status:'bad'},{sort:'bad'},{page:0}]) {req.query=query;await assert.rejects(service.report(req,now),{status:422});} +}); diff --git a/docs/API.md b/docs/API.md index 4c08b0a3..2d79c84e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,7 +3,7 @@ Generated from `api/src/routes/` by `npm run docs:api`. Do not edit this file by hand; edit the routes and regenerate. -**869 endpoints across 53 route groups.** +**870 endpoints across 53 route groups.** All paths below are relative to the API root. On a desktop install the API runs on a port derived per installation (see `local-ports.js`); in the cloud it sits @@ -15,8 +15,8 @@ behind the tenant hostname. Postman, Insomnia or Bruno, point Swagger UI or Redoc at it, or generate a client from it. It is OpenAPI 3.0.3. -**103 of 869 endpoints carry a request schema** (12%), recovered from -their `express-validator` middleware. The remaining 766 are listed with +**103 of 870 endpoints carry a request schema** (12%), recovered from +their `express-validator` middleware. The remaining 767 are listed with path, method and handler but no body, because inventing a schema is worse than admitting the gap. @@ -598,6 +598,7 @@ Mounted at `/items`. Source: `api/src/routes/items.routes.js`. | GET | `/items/expiryReminders/preference` | — | `expiryReminders.read` | | PUT | `/items/expiryReminders/preference` | — | `expiryReminders.save` | | GET | `/items/expiryReminders` | — | `expiryReminders.list` | +| GET | `/items/expiryReport` | — | `expiryReminders.report` | | POST | `/items/soldOut` | yes | `itemsController.markSoldOut` | | GET | `/items/icon-suggestion` | — | `itemsController.iconSuggestion` | | GET | `/items/printable-menu` | — | `itemsController.printableMenu` | diff --git a/docs/openapi.json b/docs/openapi.json index feaa36fe..da24ea79 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10234,6 +10234,29 @@ } } }, + "/items/expiryReport": { + "get": { + "summary": "report", + "operationId": "get_items_expiryReport", + "tags": [ + "Items" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + } + } + }, "/items/soldOut": { "post": { "summary": "markSoldOut", diff --git a/frontend/layouts/sidebar.html b/frontend/layouts/sidebar.html index 14f86d84..583c509b 100644 --- a/frontend/layouts/sidebar.html +++ b/frontend/layouts/sidebar.html @@ -195,6 +195,8 @@