TidyQuest/server/src/routes/rewards.ts
root a7cd81fe81 Initial release: TidyQuest v0.1.0
TidyQuest is a self-hosted web application that gamifies household chores
using RPG mechanics. Complete tasks, earn coins, unlock achievements, and
compete with your family on the leaderboard.

Features:
- 🎯 Health-based task tracking with visual decay over time
- 💰 Coin system with customizable rewards
- 🔥 Daily/weekly streak tracking
- 🏆 Family leaderboard (week/month/quarter/year)
- 🎖️ Automatic achievement unlocking
- 📅 Calendar view for upcoming tasks
- 🌍 Multilingual support (EN/FR/DE/ES)
- 📱 Optional Telegram notifications
- 🏖️ Vacation mode (pause task decay)
- 🐳 Docker deployment (single container)

Tech Stack:
- Frontend: React 19 + Vite + TypeScript
- Backend: Node.js + Express + TypeScript
- Database: SQLite3 with WAL mode
- Auth: JWT + bcrypt
- Deployment: Docker Compose

Includes:
- 8 room types with 60+ predefined tasks
- 10 preset rewards
- 12 achievement types
- Complete API documentation
- Security best practices guide
- Comprehensive README with examples

License: GNU Affero General Public License v3.0 (AGPL-3.0)
This ensures TidyQuest remains free and open-source forever, preventing
proprietary SaaS forks. Any modified hosted version must share source code.
2026-02-17 18:40:09 +01:00

170 lines
8.1 KiB
TypeScript

import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest, authMiddleware } from '../middleware/auth';
import { isNotificationTypeEnabled, sendTelegramMessage } from '../utils/notifications';
const router = Router();
router.use(authMiddleware);
function ensureAdmin(userId: number | undefined): boolean {
if (!userId) return false;
const requester = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId) as any;
return !!requester && requester.role === 'admin';
}
router.get('/', (req: AuthRequest, res: Response) => {
const rewards = db.prepare(
'SELECT id, title, description, costCoins, isActive, isPreset, createdBy, createdAt FROM rewards WHERE isActive = 1 ORDER BY costCoins ASC, id ASC'
).all();
const mine = db.prepare(
`SELECT rr.id, rr.rewardId, rr.userId, rr.costCoins, rr.redeemedAt, rr.status, r.title
FROM reward_redemptions rr
JOIN rewards r ON rr.rewardId = r.id
WHERE rr.userId = ?
ORDER BY rr.redeemedAt DESC
LIMIT 20`
).all(req.userId);
res.json({ rewards, mine });
});
router.get('/admin', (req: AuthRequest, res: Response) => {
if (!ensureAdmin(req.userId)) return res.status(403).json({ error: 'Admin only' });
const rewards = db.prepare(
'SELECT id, title, description, costCoins, isActive, isPreset, createdBy, createdAt FROM rewards ORDER BY isActive DESC, costCoins ASC, id ASC'
).all();
const redemptions = db.prepare(
`SELECT rr.id, rr.rewardId, rr.userId, rr.costCoins, rr.redeemedAt, rr.status, r.title, u.displayName
FROM reward_redemptions rr
JOIN rewards r ON rr.rewardId = r.id
JOIN users u ON rr.userId = u.id
ORDER BY rr.redeemedAt DESC
LIMIT 50`
).all();
res.json({ rewards, redemptions });
});
router.put('/redemptions/:id', (req: AuthRequest, res: Response) => {
if (!ensureAdmin(req.userId)) return res.status(403).json({ error: 'Admin only' });
const id = parseInt(req.params.id as string, 10);
const { status } = req.body as { status?: 'requested' | 'approved' | 'rejected' | 'cancelled' };
if (!status || !['requested', 'approved', 'rejected'].includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}
const row = db.prepare('SELECT id, userId, costCoins, status FROM reward_redemptions WHERE id = ?').get(id) as any;
if (!row) return res.status(404).json({ error: 'Redemption not found' });
db.transaction(() => {
db.prepare('UPDATE reward_redemptions SET status = ? WHERE id = ?').run(status, id);
// Refund coins exactly once when moving from requested -> rejected
if (status === 'rejected' && row.status === 'requested') {
db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?').run(row.costCoins, row.userId);
}
})();
const updated = db.prepare(
`SELECT rr.id, rr.rewardId, rr.userId, rr.costCoins, rr.redeemedAt, rr.status, r.title, u.displayName
FROM reward_redemptions rr
JOIN rewards r ON rr.rewardId = r.id
JOIN users u ON rr.userId = u.id
WHERE rr.id = ?`
).get(id);
res.json(updated);
});
router.post('/redemptions/:id/cancel', (req: AuthRequest, res: Response) => {
const id = parseInt(req.params.id as string, 10);
const row = db.prepare('SELECT id, userId, costCoins, status FROM reward_redemptions WHERE id = ?').get(id) as any;
if (!row) return res.status(404).json({ error: 'Redemption not found' });
if (row.userId !== req.userId) return res.status(403).json({ error: 'Forbidden' });
if (row.status !== 'requested') return res.status(400).json({ error: 'Only requested rewards can be cancelled' });
db.transaction(() => {
db.prepare("UPDATE reward_redemptions SET status = 'cancelled' WHERE id = ?").run(id);
db.prepare('UPDATE users SET coins = coins + ? WHERE id = ?').run(row.costCoins, row.userId);
})();
const updated = db.prepare(
`SELECT rr.id, rr.rewardId, rr.userId, rr.costCoins, rr.redeemedAt, rr.status, r.title
FROM reward_redemptions rr
JOIN rewards r ON rr.rewardId = r.id
WHERE rr.id = ?`
).get(id);
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(req.userId) as any;
res.json({ redemption: updated, coins: user?.coins ?? 0 });
});
router.post('/', (req: AuthRequest, res: Response) => {
if (!ensureAdmin(req.userId)) return res.status(403).json({ error: 'Admin only' });
const { title, description, costCoins } = req.body as { title?: string; description?: string; costCoins?: number };
const cleanTitle = String(title || '').trim();
if (!cleanTitle) return res.status(400).json({ error: 'title is required' });
const cost = Math.round(Number(costCoins));
if (!Number.isFinite(cost) || cost <= 0) return res.status(400).json({ error: 'costCoins must be > 0' });
const result = db.prepare(
'INSERT INTO rewards (title, description, costCoins, isActive, isPreset, createdBy) VALUES (?, ?, ?, 1, 0, ?)'
).run(cleanTitle, description?.trim() || null, cost, req.userId);
const created = db.prepare(
'SELECT id, title, description, costCoins, isActive, isPreset, createdBy, createdAt FROM rewards WHERE id = ?'
).get(result.lastInsertRowid);
res.status(201).json(created);
});
router.put('/:id', (req: AuthRequest, res: Response) => {
if (!ensureAdmin(req.userId)) return res.status(403).json({ error: 'Admin only' });
const id = parseInt(req.params.id as string, 10);
const reward = db.prepare('SELECT id FROM rewards WHERE id = ?').get(id) as any;
if (!reward) return res.status(404).json({ error: 'Reward not found' });
const { title, description, costCoins, isActive } = req.body as { title?: string; description?: string; costCoins?: number; isActive?: boolean };
const cleanTitle = String(title || '').trim();
if (!cleanTitle) return res.status(400).json({ error: 'title is required' });
const cost = Math.round(Number(costCoins));
if (!Number.isFinite(cost) || cost <= 0) return res.status(400).json({ error: 'costCoins must be > 0' });
db.prepare('UPDATE rewards SET title = ?, description = ?, costCoins = ?, isActive = ? WHERE id = ?')
.run(cleanTitle, description?.trim() || null, cost, isActive === false ? 0 : 1, id);
const updated = db.prepare(
'SELECT id, title, description, costCoins, isActive, isPreset, createdBy, createdAt FROM rewards WHERE id = ?'
).get(id);
res.json(updated);
});
router.delete('/:id', (req: AuthRequest, res: Response) => {
if (!ensureAdmin(req.userId)) return res.status(403).json({ error: 'Admin only' });
const id = parseInt(req.params.id as string, 10);
const reward = db.prepare('SELECT id FROM rewards WHERE id = ?').get(id) as any;
if (!reward) return res.status(404).json({ error: 'Reward not found' });
db.prepare('DELETE FROM rewards WHERE id = ?').run(id);
res.json({ success: true });
});
router.post('/:id/redeem', (req: AuthRequest, res: Response) => {
const id = parseInt(req.params.id as string, 10);
const reward = db.prepare('SELECT id, title, costCoins, isActive FROM rewards WHERE id = ?').get(id) as any;
if (!reward || !reward.isActive) return res.status(404).json({ error: 'Reward not available' });
const me = db.prepare('SELECT id, coins, displayName FROM users WHERE id = ?').get(req.userId) as any;
if (!me) return res.status(404).json({ error: 'User not found' });
if (me.coins < reward.costCoins) return res.status(400).json({ error: 'Not enough coins' });
const tx = db.transaction(() => {
db.prepare('UPDATE users SET coins = coins - ? WHERE id = ?').run(reward.costCoins, req.userId);
const redemption = db.prepare(
"INSERT INTO reward_redemptions (rewardId, userId, costCoins, status) VALUES (?, ?, ?, 'requested')"
).run(id, req.userId, reward.costCoins);
return redemption.lastInsertRowid;
});
const redemptionId = tx();
const user = db.prepare('SELECT coins FROM users WHERE id = ?').get(req.userId) as any;
const redemption = db.prepare(
'SELECT id, rewardId, userId, costCoins, redeemedAt, status FROM reward_redemptions WHERE id = ?'
).get(redemptionId);
if (isNotificationTypeEnabled('rewardRequest')) {
void sendTelegramMessage(
`🎁 Reward request: ${me.displayName} requested "${reward.title}" (${reward.costCoins} coins).`
);
}
res.json({ redemption, coins: user.coins });
});
export default router;