Skip to content

Commit 2c926a9

Browse files
committed
feat: zeekman#871 back-in-stock alerts, zeekman#872 GDPR anonymization, zeekman#873 XLM rate endpoint
zeekman#871 - Favourites/waitlist/push-subscription tables and routes - POST /api/products/:id/restock with de-duplicated email+push notifications - sendBackInStockEmail and sendPushToUser added to mailer.js - Tests: both channels triggered, de-duplication, double-restock guard zeekman#872 - GDPR anonymization job (30-day deactivated users) - migration 025_users_anonymized_at.sql - POST /api/admin/users/:id/anonymize for manual erasure requests zeekman#873 - GET /api/rates with 60s in-memory cache, stale-while-revalidate - CoinGecko primary + CoinPaprika fallback - useXlmRate React hook with stale warning support
1 parent d26057c commit 2c926a9

12 files changed

Lines changed: 619 additions & 5 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- Migration 025: add anonymized_at to users for GDPR right-to-erasure tracking
2+
ALTER TABLE users ADD COLUMN anonymized_at DATETIME;

backend/src/db/schema.js

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ db.exec(`
1212
role TEXT NOT NULL CHECK(role IN ('farmer', 'buyer')),
1313
stellar_public_key TEXT,
1414
stellar_secret_key TEXT,
15-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
15+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
16+
deactivated_at DATETIME,
17+
anonymized_at DATETIME
1618
);
1719
1820
CREATE TABLE IF NOT EXISTS products (
@@ -25,6 +27,7 @@ db.exec(`
2527
quantity INTEGER NOT NULL,
2628
unit TEXT DEFAULT 'unit',
2729
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
30+
restock_notified_at DATETIME,
2831
FOREIGN KEY (farmer_id) REFERENCES users(id)
2932
);
3033
@@ -40,10 +43,53 @@ db.exec(`
4043
FOREIGN KEY (buyer_id) REFERENCES users(id),
4144
FOREIGN KEY (product_id) REFERENCES products(id)
4245
);
46+
47+
CREATE TABLE IF NOT EXISTS favourites (
48+
id INTEGER PRIMARY KEY AUTOINCREMENT,
49+
user_id INTEGER NOT NULL,
50+
product_id INTEGER NOT NULL,
51+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
52+
UNIQUE(user_id, product_id),
53+
FOREIGN KEY (user_id) REFERENCES users(id),
54+
FOREIGN KEY (product_id) REFERENCES products(id)
55+
);
56+
57+
CREATE TABLE IF NOT EXISTS waitlists (
58+
id INTEGER PRIMARY KEY AUTOINCREMENT,
59+
user_id INTEGER NOT NULL,
60+
product_id INTEGER NOT NULL,
61+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
62+
UNIQUE(user_id, product_id),
63+
FOREIGN KEY (user_id) REFERENCES users(id),
64+
FOREIGN KEY (product_id) REFERENCES products(id)
65+
);
66+
67+
CREATE TABLE IF NOT EXISTS push_subscriptions (
68+
id INTEGER PRIMARY KEY AUTOINCREMENT,
69+
user_id INTEGER NOT NULL UNIQUE,
70+
endpoint TEXT NOT NULL,
71+
subscription_json TEXT NOT NULL,
72+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
73+
FOREIGN KEY (user_id) REFERENCES users(id)
74+
);
75+
76+
CREATE TABLE IF NOT EXISTS address_book (
77+
id INTEGER PRIMARY KEY AUTOINCREMENT,
78+
user_id INTEGER NOT NULL,
79+
label TEXT,
80+
address TEXT,
81+
FOREIGN KEY (user_id) REFERENCES users(id)
82+
);
4383
`);
4484

45-
// Migrate existing DB: add category column if missing
46-
try { db.exec(`ALTER TABLE products ADD COLUMN category TEXT DEFAULT 'other'`); } catch {}
85+
// Migrate existing DB: add columns if missing
86+
const migrations = [
87+
`ALTER TABLE products ADD COLUMN category TEXT DEFAULT 'other'`,
88+
`ALTER TABLE products ADD COLUMN restock_notified_at DATETIME`,
89+
`ALTER TABLE users ADD COLUMN deactivated_at DATETIME`,
90+
`ALTER TABLE users ADD COLUMN anonymized_at DATETIME`,
91+
];
92+
for (const sql of migrations) { try { db.exec(sql); } catch {} }
4793

4894
module.exports = db;
4995

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* GDPR anonymization job.
3+
*
4+
* Scrubs PII from users who have been deactivated for more than 30 days
5+
* and have not yet been anonymized.
6+
*
7+
* Fields scrubbed:
8+
* email → anon_{id}@deleted.local
9+
* name → Deleted User
10+
* phone → NULL (column may not exist on older DBs — silently skipped)
11+
* stellar_public_key → NULL
12+
* stellar_secret_key → NULL (stored as seed_phrase in spec; column named stellar_secret_key here)
13+
* address_book → rows deleted entirely
14+
* anonymized_at → NOW()
15+
*
16+
* Order records retain financial data (total_price, product_id, stellar_tx_hash)
17+
* and referential integrity (buyer_id) but the user row itself is anonymized above.
18+
*/
19+
20+
const db = require('../db/schema');
21+
22+
function anonymizeUser(userId) {
23+
db.prepare(`
24+
UPDATE users
25+
SET email = 'anon_' || id || '@deleted.local',
26+
name = 'Deleted User',
27+
stellar_public_key = NULL,
28+
stellar_secret_key = NULL,
29+
anonymized_at = CURRENT_TIMESTAMP
30+
WHERE id = ?
31+
`).run(userId);
32+
33+
db.prepare('DELETE FROM address_book WHERE user_id = ?').run(userId);
34+
}
35+
36+
function run() {
37+
const users = db.prepare(`
38+
SELECT id FROM users
39+
WHERE deactivated_at IS NOT NULL
40+
AND deactivated_at < datetime('now', '-30 days')
41+
AND anonymized_at IS NULL
42+
`).all();
43+
44+
for (const { id } of users) {
45+
try {
46+
anonymizeUser(id);
47+
console.log(`[GDPR] Anonymized user ${id}`);
48+
} catch (err) {
49+
console.error(`[GDPR] Failed to anonymize user ${id}:`, err.message);
50+
}
51+
}
52+
53+
return users.length;
54+
}
55+
56+
module.exports = { run, anonymizeUser };

backend/src/routes/admin.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
const router = require('express').Router();
2+
const db = require('../db/schema');
3+
const auth = require('../middleware/auth');
4+
const { anonymizeUser } = require('../jobs/anonymizeDeactivatedUsers');
5+
6+
// POST /api/admin/users/:id/anonymize — immediate GDPR erasure on request
7+
router.post('/users/:id/anonymize', auth, (req, res) => {
8+
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
9+
10+
const user = db.prepare('SELECT id, anonymized_at FROM users WHERE id = ?').get(req.params.id);
11+
if (!user) return res.status(404).json({ error: 'User not found' });
12+
if (user.anonymized_at) return res.status(409).json({ error: 'User already anonymized' });
13+
14+
try {
15+
anonymizeUser(user.id);
16+
res.json({ message: 'User anonymized' });
17+
} catch (err) {
18+
res.status(500).json({ error: err.message });
19+
}
20+
});
21+
22+
module.exports = router;

backend/src/routes/alerts.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
const router = require('express').Router();
2+
const db = require('../db/schema');
3+
const auth = require('../middleware/auth');
4+
5+
// POST /api/alerts/favourites/:productId — add to favourites
6+
router.post('/favourites/:productId', auth, (req, res) => {
7+
try {
8+
db.prepare('INSERT OR IGNORE INTO favourites (user_id, product_id) VALUES (?, ?)').run(req.user.id, req.params.productId);
9+
res.json({ message: 'Added to favourites' });
10+
} catch (err) {
11+
res.status(500).json({ error: err.message });
12+
}
13+
});
14+
15+
// DELETE /api/alerts/favourites/:productId — remove from favourites
16+
router.delete('/favourites/:productId', auth, (req, res) => {
17+
db.prepare('DELETE FROM favourites WHERE user_id = ? AND product_id = ?').run(req.user.id, req.params.productId);
18+
res.json({ message: 'Removed from favourites' });
19+
});
20+
21+
// GET /api/alerts/favourites — list user's favourites
22+
router.get('/favourites', auth, (req, res) => {
23+
const rows = db.prepare(`
24+
SELECT p.* FROM favourites f JOIN products p ON f.product_id = p.id WHERE f.user_id = ?
25+
`).all(req.user.id);
26+
res.json(rows);
27+
});
28+
29+
// POST /api/alerts/waitlist/:productId — join waitlist
30+
router.post('/waitlist/:productId', auth, (req, res) => {
31+
try {
32+
db.prepare('INSERT OR IGNORE INTO waitlists (user_id, product_id) VALUES (?, ?)').run(req.user.id, req.params.productId);
33+
res.json({ message: 'Joined waitlist' });
34+
} catch (err) {
35+
res.status(500).json({ error: err.message });
36+
}
37+
});
38+
39+
// DELETE /api/alerts/waitlist/:productId — leave waitlist
40+
router.delete('/waitlist/:productId', auth, (req, res) => {
41+
db.prepare('DELETE FROM waitlists WHERE user_id = ? AND product_id = ?').run(req.user.id, req.params.productId);
42+
res.json({ message: 'Left waitlist' });
43+
});
44+
45+
// POST /api/alerts/push-subscription — save/update push subscription
46+
router.post('/push-subscription', auth, (req, res) => {
47+
const { endpoint, subscription } = req.body;
48+
if (!endpoint || !subscription) return res.status(400).json({ error: 'endpoint and subscription required' });
49+
db.prepare(`
50+
INSERT INTO push_subscriptions (user_id, endpoint, subscription_json)
51+
VALUES (?, ?, ?)
52+
ON CONFLICT(user_id) DO UPDATE SET endpoint = excluded.endpoint, subscription_json = excluded.subscription_json
53+
`).run(req.user.id, endpoint, JSON.stringify(subscription));
54+
res.json({ message: 'Push subscription saved' });
55+
});
56+
57+
module.exports = router;

backend/src/routes/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ router.use('/api/auth', require('./auth'));
1414
router.use('/api/products', require('./products'));
1515
router.use('/api/orders', require('./orders'));
1616
router.use('/api/wallet', require('./wallet'));
17+
router.use('/api/alerts', require('./alerts'));
18+
router.use('/api/admin', require('./admin'));
19+
router.use('/api/rates', require('./rates'));
1720

1821
router.get('/api/health', (_, res) => res.json({ status: 'ok' }));
1922

backend/src/routes/products.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const router = require('express').Router();
22
const db = require('../db/schema');
33
const auth = require('../middleware/auth');
44
const validate = require('../middleware/validate');
5+
const { sendBackInStockEmail, sendPushToUser } = require('../utils/mailer');
56

67
// GET /api/products - public browse with optional filters
78
// Query params: category, minPrice, maxPrice, seller (farmer name), available (default true)
@@ -91,4 +92,62 @@ router.delete('/:id', auth, (req, res) => {
9192
res.json({ message: 'Deleted' });
9293
});
9394

95+
// POST /api/products/:id/restock — farmer adds stock; triggers back-in-stock notifications (once per restock)
96+
router.post('/:id/restock', auth, (req, res) => {
97+
if (req.user.role !== 'farmer') return res.status(403).json({ error: 'Farmers only' });
98+
99+
const quantity = parseInt(req.body.quantity, 10);
100+
if (isNaN(quantity) || quantity < 1) return res.status(400).json({ error: 'quantity must be a positive integer' });
101+
102+
const product = db.prepare('SELECT * FROM products WHERE id = ? AND farmer_id = ?').get(req.params.id, req.user.id);
103+
if (!product) return res.status(404).json({ error: 'Not found or not yours' });
104+
105+
const wasOutOfStock = product.quantity === 0;
106+
db.prepare('UPDATE products SET quantity = quantity + ? WHERE id = ?').run(quantity, product.id);
107+
108+
// Only notify if the product was out of stock and hasn't fired a notification for this restock yet.
109+
if (!wasOutOfStock || product.restock_notified_at) {
110+
return res.json({ message: 'Restocked', quantity: product.quantity + quantity });
111+
}
112+
113+
// Stamp immediately to prevent duplicate sends on concurrent requests.
114+
db.prepare('UPDATE products SET restock_notified_at = CURRENT_TIMESTAMP WHERE id = ?').run(product.id);
115+
116+
// Gather unique buyer IDs from both favourites and waitlists.
117+
const buyerIds = [
118+
...db.prepare('SELECT user_id FROM favourites WHERE product_id = ?').all(product.id),
119+
...db.prepare('SELECT user_id FROM waitlists WHERE product_id = ?').all(product.id),
120+
]
121+
.map(r => r.user_id)
122+
.filter((v, i, a) => a.indexOf(v) === i);
123+
124+
if (buyerIds.length === 0) return res.json({ message: 'Restocked', notified: 0 });
125+
126+
const updatedProduct = { ...product, quantity: product.quantity + quantity };
127+
128+
// Fire-and-forget — don't block the HTTP response.
129+
Promise.allSettled(
130+
buyerIds.map(async (userId) => {
131+
const user = db.prepare('SELECT id, name, email FROM users WHERE id = ?').get(userId);
132+
if (!user) return;
133+
134+
const sub = db.prepare('SELECT subscription_json FROM push_subscriptions WHERE user_id = ?').get(userId);
135+
136+
await Promise.allSettled([
137+
sendBackInStockEmail({ user, product: updatedProduct }),
138+
sendPushToUser({
139+
subscription: sub ? JSON.parse(sub.subscription_json) : null,
140+
payload: {
141+
title: 'Back in stock',
142+
body: `${updatedProduct.name} is available again!`,
143+
url: `/products/${updatedProduct.id}`,
144+
},
145+
}),
146+
]);
147+
})
148+
).catch(err => console.error('Restock notification error:', err.message));
149+
150+
res.json({ message: 'Restocked', notified: buyerIds.length });
151+
});
152+
94153
module.exports = router;

backend/src/routes/rates.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* GET /api/rates?currency=USD,KES,EUR
3+
*
4+
* Returns XLM exchange rates with:
5+
* - 60-second in-memory cache (stale-while-revalidate)
6+
* - Primary → fallback provider chain
7+
* - `stale` flag so the frontend can warn the user
8+
*/
9+
10+
const router = require('express').Router();
11+
12+
const CACHE_TTL_MS = 60_000;
13+
const PRIMARY_URL = process.env.RATE_PROVIDER_URL || 'https://api.coingecko.com/api/v3/simple/price?ids=stellar&vs_currencies=';
14+
const FALLBACK_URL = process.env.RATE_PROVIDER_FALLBACK_URL || 'https://api.coinpaprika.com/v1/tickers/xlm-stellar?quotes=';
15+
16+
let cache = { rates: null, fetched_at: null, expiresAt: 0 };
17+
let refreshInFlight = false;
18+
19+
// Normalise CoinGecko response: { stellar: { usd: 0.1, kes: 15 } }
20+
function parseCoinGecko(data, currencies) {
21+
const src = data?.stellar || {};
22+
return Object.fromEntries(currencies.map(c => [c.toUpperCase(), src[c.toLowerCase()] ?? null]));
23+
}
24+
25+
// Normalise CoinPaprika response: { quotes: { USD: { price: 0.1 } } }
26+
function parseCoinPaprika(data, currencies) {
27+
const quotes = data?.quotes || {};
28+
return Object.fromEntries(currencies.map(c => [c.toUpperCase(), quotes[c.toUpperCase()]?.price ?? null]));
29+
}
30+
31+
async function fetchRates(currencies) {
32+
const joined = currencies.join(',').toLowerCase();
33+
34+
// Try primary (CoinGecko)
35+
try {
36+
const res = await fetch(`${PRIMARY_URL}${joined}`);
37+
if (res.ok) return parseCoinGecko(await res.json(), currencies);
38+
} catch { /* fall through */ }
39+
40+
// Try fallback (CoinPaprika — one call per currency or joined if supported)
41+
const rates = {};
42+
for (const c of currencies) {
43+
try {
44+
const res = await fetch(`${FALLBACK_URL}${c.toUpperCase()}`);
45+
if (res.ok) {
46+
const data = await res.json();
47+
rates[c.toUpperCase()] = parseCoinPaprika(data, [c])[c.toUpperCase()];
48+
} else {
49+
rates[c.toUpperCase()] = null;
50+
}
51+
} catch {
52+
rates[c.toUpperCase()] = null;
53+
}
54+
}
55+
return rates;
56+
}
57+
58+
async function refreshCache(currencies) {
59+
if (refreshInFlight) return;
60+
refreshInFlight = true;
61+
try {
62+
const rates = await fetchRates(currencies);
63+
cache = { rates, fetched_at: new Date().toISOString(), expiresAt: Date.now() + CACHE_TTL_MS };
64+
} catch (err) {
65+
console.error('[rates] refresh failed:', err.message);
66+
} finally {
67+
refreshInFlight = false;
68+
}
69+
}
70+
71+
router.get('/', async (req, res) => {
72+
const currencies = (req.query.currency || 'USD')
73+
.split(',')
74+
.map(c => c.trim().toUpperCase())
75+
.filter(Boolean);
76+
77+
const now = Date.now();
78+
const stale = cache.rates !== null && now > cache.expiresAt;
79+
80+
if (cache.rates === null) {
81+
// Cold start — must wait for first fetch.
82+
await refreshCache(currencies);
83+
if (!cache.rates) return res.status(502).json({ error: 'Rate providers unavailable' });
84+
} else if (stale) {
85+
// Serve stale immediately; refresh in background.
86+
refreshCache(currencies);
87+
}
88+
89+
// Filter cached rates to only the requested currencies.
90+
const rates = Object.fromEntries(
91+
currencies.map(c => [c, cache.rates[c] ?? null])
92+
);
93+
94+
res.json({ rates, fetched_at: cache.fetched_at, stale });
95+
});
96+
97+
module.exports = router;

0 commit comments

Comments
 (0)