Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down
21 changes: 14 additions & 7 deletions api/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down
4 changes: 2 additions & 2 deletions api/src/realtime/push.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,15 @@ 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);
if (!vapid) return { sent: 0 };

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) {
Expand Down
1 change: 1 addition & 0 deletions api/src/routes/items.routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 37 additions & 6 deletions api/src/services/item-expiry-reminders.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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: {
Expand All @@ -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' }],
Expand Down Expand Up @@ -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,
};
14 changes: 14 additions & 0 deletions api/tests/item-expiry-reminders.integration.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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});}
});
7 changes: 4 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.

Expand Down Expand Up @@ -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` |
Expand Down
23 changes: 23 additions & 0 deletions docs/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions frontend/layouts/sidebar.html
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,8 @@ <h5 class="menu-title">
<lang class="lang_itemcategory_title">Categories</lang>
</a>
</li>
<li><a data-module="item" data-access="read" href="#/expiryreport" id="view_expiryreport_page"><i class="feather icon-calendar mr-2"></i><span><lang class="lang_expiry_report_short">Expiry report</lang></span></a></li>
<li><a data-module="item" data-access="read" href="#/itemsettings" id="view_itemsettings_page"><i class="feather icon-settings mr-2"></i><span><lang class="lang_item_settings">Item settings</lang></span></a></li>
<li id="inventoryCountsModule"><a data-module="item" data-access="read" href="#/inventorycounts" id="view_inventorycounts_page"><img src="static/images/svg-icon/clipboard.svg" class="img-fluid" alt="Stock Counts" loading="lazy" decoding="async"><span><lang class="lang_stock_counts">Stock Counts</lang></span></a></li>

<li id="variantModule">
Expand Down
12 changes: 12 additions & 0 deletions frontend/modules/expiryReport.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<div class="vertical-layout page_loader" id="expiryreport" style="display:none"><div id="containerbar"><div class="breadcrumbbar crumbbar_padding">
<div class="d-flex justify-content-between align-items-center mb-3"><h4><lang class="lang_expiry_report">Expiring products report</lang></h4><a class="btn btn-outline-primary" href="#/itemsettings"><lang class="lang_item_settings">Item settings</lang></a></div></div></div><div class="contentbar">
<div class="card"><div class="card-body"><p><lang class="lang_expiry_report_help">Products with stock and a saved expiry date. Available even when reminders are off.</lang></p>
<form id="expiry_report_filters" class="row align-items-end">
<div class="col-md-3 mb-3"><label for="expiry_report_search"><lang class="lang_search">Search</lang></label><input id="expiry_report_search" class="form-control" maxlength="100" type="search"></div>
<div class="col-md-2 mb-3"><label for="expiry_report_days"><lang class="lang_days_ahead">Days ahead</lang></label><input id="expiry_report_days" class="form-control" type="number" min="0" max="365" step="1" value="30" required></div>
<div class="col-md-2 mb-3"><label for="expiry_report_status"><lang class="lang_status">Status:</lang></label><select id="expiry_report_status" class="form-control"><option value="all" data-t="lang_all">All</option><option value="expired" data-t="lang_expired">Expired</option><option value="upcoming" data-t="lang_today_and_upcoming">Today and upcoming</option></select></div>
<div class="col-md-3 mb-3"><label for="expiry_report_sort"><lang class="lang_sort">Sort</lang></label><select id="expiry_report_sort" class="form-control"><option value="asc" data-t="lang_earliest_expiry_first">Earliest expiry first</option><option value="desc" data-t="lang_latest_expiry_first">Latest expiry first</option></select></div>
<div class="col-md-2 mb-3"><button class="btn btn-primary" type="submit"><lang class="lang_refresh">Refresh</lang></button></div></form>
<p id="expiry_report_summary" role="status"></p><div class="table-responsive"><table class="table"><thead><tr><th><lang class="lang_itemname_title">Item Name</lang></th><th><lang class="lang_sku">SKU</lang></th><th><lang class="lang_expiry_date">Expiry Date</lang></th><th><lang class="lang_quantity">Quantity</lang></th><th><lang class="lang_status">Status:</lang></th></tr></thead><tbody id="expiry_report_rows"></tbody></table></div>
<div class="d-flex justify-content-between align-items-center"><button type="button" class="btn btn-outline-primary" id="expiry_report_previous"><lang class="lang_previous">Previous</lang></button><span id="expiry_report_page"></span><button type="button" class="btn btn-outline-primary" id="expiry_report_next"><lang class="lang_next">Next</lang></button></div>
</div></div></div></div>
15 changes: 15 additions & 0 deletions frontend/modules/itemSettings.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<div class="vertical-layout page_loader" id="itemsettings" style="display:none"><div id="containerbar"><div class="breadcrumbbar crumbbar_padding">
<div class="d-flex justify-content-between align-items-center mb-3"><h4><lang class="lang_item_settings">Item settings</lang></h4><a class="btn btn-outline-primary" href="#/items"><lang class="lang_item_master_title">Item List</lang></a></div></div></div><div class="contentbar">
<div class="card mb-3"><div class="card-header"><h5><lang class="lang_stock_preference">Stock preference</lang></h5></div><div class="card-body">
<select id="item_stock_preference" class="form-control" aria-label="Stock for new items" data-t-aria-label="lang_stock_for_new_items"><option value="" data-t="lang_choose_a_preference">Choose a preference</option><option value="always_available" data-t="lang_always_available">Always available</option><option value="track_quantities" data-t="lang_track_quantities">Track quantities</option></select>
<p><strong><lang class="lang_always_available_2">Always available:</lang></strong> <lang class="lang_sell_without_entering_stock_quantities_no">Sell without entering stock quantities. No low-stock alerts. Suitable for made-to-order dishes.</lang></p>
<p><strong><lang class="lang_track_quantities_2">Track quantities:</lang></strong> <lang class="lang_enter_stock_received_and_count_each_sale_l">Enter stock received and count each sale. Low-stock alerts apply. Suitable for packaged goods.</lang></p>
<p><lang class="lang_applies_to_new_items_in_this_branch_existi">Applies to new items in this branch. Existing items keep their stock settings. You can still mark an item sold out manually.</lang></p>
<button class="btn btn-primary" type="button" id="item_settings_save_stock"><lang class="lang_save_title">Save</lang></button></div></div>
<div class="card"><div class="card-header"><h5><lang class="lang_expiry_reminders">Expiry reminders</lang></h5></div><div class="card-body">
<p><lang class="lang_expiry_reminders_help">Reminders appear in the POS bell for products with stock and a saved expiry date. Dates use the shop timezone. No email or WhatsApp messages are sent.</lang></p>
<div id="item_expiry_settings"><label class="d-block"><input type="checkbox" id="expiry_reminders_enabled"> <lang class="lang_enable_expiry_reminders">Enable expiry reminders</lang></label>
<label for="expiry_reminders_days"><lang class="lang_expiry_days_ahead">Days before expiry</lang></label><input class="form-control mb-3" style="max-width:160px" type="number" min="0" max="365" step="1" id="expiry_reminders_days">
<button class="btn btn-primary" type="button" id="expiry_reminders_save"><lang class="lang_save_title">Save</lang></button></div>
<a class="btn btn-outline-primary mt-3" href="#/expiryreport"><lang class="lang_expiry_report">Expiring products report</lang></a><p id="item_settings_status" class="mt-3" role="status"></p>
</div></div></div></div>
Loading
Loading