librechat-balances/routes/requests.js
DJP 81e18f8a73 Add email notifications with one-click approve/reject from email
When a user submits a credit request, an email is sent to all
configured NOTIFY_TO recipients with approve buttons (5M, 10M, 20M),
a custom amount option, and a reject button. Each button is a
signed HMAC-SHA256 webhook URL that expires after WEBHOOK_TTL_HOURS.

Clicking approve from email processes the top-up identical to the
admin dashboard — balance update, history log, request status change.
Double-approval protection prevents the same link from being used
twice. Portal approval still works alongside email approval.

New dependencies: nodemailer
New files: services/email.js, routes/webhooks.js
Modified: server.js, routes/requests.js, .env.example

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 09:50:44 -04:00

124 lines
4 KiB
JavaScript

const { Router } = require('express');
const requestService = require('../services/requests');
const balanceService = require('../services/balance');
const history = require('../services/history');
const { sendApprovalEmail } = require('../services/email');
const { MongoClient } = require('mongodb');
const publicRouter = Router();
const adminRouter = Router();
// Public: submit a credit request (no auth)
publicRouter.post('/', (req, res) => {
try {
const { email, omgJobNumber } = req.body;
if (!email || !omgJobNumber) {
return res.status(400).json({ error: 'Email and OMG job number are required' });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Invalid email address' });
}
const request = requestService.createRequest(email, omgJobNumber);
sendApprovalEmail(request).catch(err =>
console.error('[email] Failed to send approval notification:', err)
);
res.json({ success: true, id: request.id });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: get all requests with optional status filter
adminRouter.get('/', (req, res) => {
try {
const { status } = req.query;
if (status === 'pending') return res.json({ requests: requestService.getPending() });
if (status === 'processed') return res.json({ requests: requestService.getProcessed() });
return res.json({ requests: requestService.getAll() });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: get pending count
adminRouter.get('/count', (req, res) => {
try {
const pending = requestService.getPending();
res.json({ count: pending.length });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: approve a request with a top-up amount
adminRouter.post('/:id/approve', async (req, res) => {
try {
const { amount } = req.body;
if (typeof amount !== 'number' || amount <= 0) {
return res.status(400).json({ error: 'Invalid amount' });
}
const requests = requestService.getAll();
const request = requests.find(r => r.id === req.params.id);
if (!request) {
return res.status(404).json({ error: 'Request not found' });
}
if (request.status !== 'pending') {
return res.status(400).json({ error: 'Request already processed' });
}
// Find user by email and top up
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/LibreChat';
const client = new MongoClient(MONGO_URI);
await client.connect();
const db = client.db();
const user = await db.collection('users').findOne(
{ email: request.email.toLowerCase() },
{ projection: { _id: 1, email: 1 } },
);
if (!user) {
await client.close();
return res.status(404).json({ error: `No LibreChat user found with email: ${request.email}` });
}
const result = await balanceService.addBalance(db, user._id.toString(), amount);
await client.close();
history.log({
email: request.email,
action: 'approve',
amount,
source: 'request',
omgJobNumber: request.omgJobNumber,
balanceAfter: result.tokenCredits,
});
const updated = requestService.updateRequest(req.params.id, 'approved', amount);
res.json({ success: true, request: updated });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Admin: reject a request
adminRouter.post('/:id/reject', (req, res) => {
try {
const requests = requestService.getAll();
const request = requests.find(r => r.id === req.params.id);
if (!request) {
return res.status(404).json({ error: 'Request not found' });
}
if (request.status !== 'pending') {
return res.status(400).json({ error: 'Request already processed' });
}
const updated = requestService.updateRequest(req.params.id, 'rejected', null);
res.json({ success: true, request: updated });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
module.exports = { publicRouter, adminRouter };