← back to AbramsOS
routes/reorders.js
125 lines
// Reorders — "what we order a lot" + best-price ("save money") tracking.
const express = require('express');
const db = require('../lib/db');
const audit = require('../lib/audit');
const { id } = require('../lib/ids');
const { nextDueDays, daysUntil, toISODate } = require('../lib/recurrence');
const router = express.Router();
const DEV_USER_ID = 'user_steve';
const CATEGORIES = ['grocery', 'household', 'pet', 'office', 'health', 'other'];
function decorate(r) {
const d = r.next_due_date ? daysUntil(r.next_due_date) : null;
// savings = how much cheaper the best price is vs. what we typically pay
const savings = r.typical_price != null && r.best_price != null
? Number(r.typical_price) - Number(r.best_price)
: null;
return {
...r,
days_until: d,
is_due: r.status === 'active' && d != null && d <= 0,
savings: savings != null && savings > 0 ? Number(savings.toFixed(2)) : 0,
};
}
async function listItems(userId) {
const r = await db.query(
`SELECT * FROM reorder_item WHERE user_id = $1 ORDER BY (next_due_date IS NULL), next_due_date ASC, created_at DESC`,
[userId]
);
return r.rows.map(decorate);
}
router.get('/reorders', async (_req, res) => {
const items = await listItems(DEV_USER_ID);
const totalSavings = items.reduce((s, x) => s + (x.savings || 0), 0);
res.render('reorders', { items, categories: CATEGORIES, totalSavings });
});
router.get('/api/reorders', async (_req, res) => {
res.json(await listItems(DEV_USER_ID));
});
router.post('/api/reorders', async (req, res) => {
try {
const b = req.body || {};
if (!b.name) return res.status(400).json({ error: 'name required' });
const cadence = b.reorder_cadence_days ? parseInt(b.reorder_cadence_days, 10) : null;
const nextDue = b.last_ordered_at && cadence ? nextDueDays(b.last_ordered_at, cadence) : (b.next_due_date || null);
const itemId = id('reorder');
await db.query(
`INSERT INTO reorder_item (id, user_id, name, merchant, category, unit, typical_price, best_price, best_price_source, currency, reorder_cadence_days, last_ordered_at, next_due_date, url, status, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
[
itemId, DEV_USER_ID, b.name, b.merchant || null,
CATEGORIES.includes(b.category) ? b.category : (b.category || null),
b.unit || null, b.typical_price || null, b.best_price || null, b.best_price_source || null,
b.currency || 'USD', cadence, b.last_ordered_at || null, nextDue, b.url || null, 'active', b.notes || null,
]
);
await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'reorder_item', objectId: itemId, eventType: 'reorder_created', metadata: { name: b.name } });
const r = await db.query(`SELECT * FROM reorder_item WHERE id = $1`, [itemId]);
res.status(201).json(decorate(r.rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/api/reorders/:id', async (req, res) => {
try {
const b = req.body || {};
const fields = ['name', 'merchant', 'category', 'unit', 'typical_price', 'best_price', 'best_price_source', 'currency', 'reorder_cadence_days', 'last_ordered_at', 'next_due_date', 'url', 'status', 'notes'];
const sets = [];
const vals = [];
let i = 1;
for (const f of fields) {
if (b[f] === undefined) continue;
sets.push(`${f} = $${i++}`);
vals.push(b[f] === '' ? null : b[f]);
}
if (!sets.length) return res.status(400).json({ error: 'no fields to update' });
sets.push(`updated_at = now()`);
vals.push(req.params.id, DEV_USER_ID);
const r = await db.query(
`UPDATE reorder_item SET ${sets.join(', ')} WHERE id = $${i++} AND user_id = $${i} RETURNING *`,
vals
);
if (!r.rows.length) return res.status(404).json({ error: 'not found' });
await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'reorder_item', objectId: req.params.id, eventType: 'reorder_updated' });
res.json(decorate(r.rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Mark ordered → stamp last_ordered_at=today and roll next_due_date forward by cadence.
router.post('/api/reorders/:id/ordered', async (req, res) => {
try {
const cur = await db.query(`SELECT * FROM reorder_item WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
if (!cur.rows.length) return res.status(404).json({ error: 'not found' });
const item = cur.rows[0];
const today = toISODate(new Date());
const nextDue = item.reorder_cadence_days ? nextDueDays(today, item.reorder_cadence_days) : null;
const r = await db.query(
`UPDATE reorder_item SET last_ordered_at = $1, next_due_date = $2, updated_at = now()
WHERE id = $3 AND user_id = $4 RETURNING *`,
[today, nextDue, req.params.id, DEV_USER_ID]
);
await db.query(`DELETE FROM calendar_reminder WHERE user_id = $1 AND owner_table = 'reorder_item' AND owner_id = $2`, [DEV_USER_ID, req.params.id]);
await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'reorder_item', objectId: req.params.id, eventType: 'reorder_ordered', metadata: { ordered_on: today, next_due: nextDue } });
res.json(decorate(r.rows[0]));
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/api/reorders/:id/delete', async (req, res) => {
await db.query(`DELETE FROM reorder_item WHERE id = $1 AND user_id = $2`, [req.params.id, DEV_USER_ID]);
await audit.log({ actorType: 'user', actorId: DEV_USER_ID, objectType: 'reorder_item', objectId: req.params.id, eventType: 'reorder_deleted' });
res.json({ ok: true });
});
module.exports = router;