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.
170 lines
8.1 KiB
TypeScript
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;
|