v1.0.0
This commit is contained in:
25
backend/src/db/db.js
Normal file
25
backend/src/db/db.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const DATABASE_PATH = process.env.DATABASE_PATH || path.join(__dirname, '../../data/app.db');
|
||||
fs.mkdirSync(path.dirname(DATABASE_PATH), { recursive: true });
|
||||
|
||||
const db = new Database(DATABASE_PATH);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
const schema = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||
db.exec(schema);
|
||||
|
||||
const DEFAULT_CATEGORIES = [
|
||||
{ name: 'Jedzenie', icon: 'shopping_cart', color: '#22c55e' },
|
||||
{ name: 'Mieszkanie', icon: 'home', color: '#3b82f6' },
|
||||
{ name: 'Rachunki', icon: 'bolt', color: '#f59e0b' },
|
||||
{ name: 'Transport', icon: 'directions_car', color: '#8b5cf6' },
|
||||
{ name: 'Restauracje', icon: 'restaurant', color: '#ef4444' },
|
||||
{ name: 'Rozrywka', icon: 'celebration', color: '#ec4899' },
|
||||
{ name: 'Inne', icon: 'inventory_2', color: '#6b7280' },
|
||||
];
|
||||
|
||||
module.exports = { db, DEFAULT_CATEGORIES };
|
||||
82
backend/src/db/schema.sql
Normal file
82
backend/src/db/schema.sql
Normal file
@@ -0,0 +1,82 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
avatar_emoji TEXT NOT NULL DEFAULT '🙂',
|
||||
email_notifications INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS password_resets (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS households (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'PLN',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS household_members (
|
||||
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (household_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invites (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
code TEXT UNIQUE NOT NULL,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🛒',
|
||||
color TEXT NOT NULL DEFAULT '#6b7280'
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS expenses (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
payer_id TEXT NOT NULL REFERENCES users(id),
|
||||
amount REAL NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
category_id TEXT REFERENCES categories(id) ON DELETE SET NULL,
|
||||
expense_date TEXT NOT NULL,
|
||||
split_type TEXT NOT NULL CHECK (split_type IN ('equal', 'exact', 'full')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS expense_shares (
|
||||
expense_id TEXT NOT NULL REFERENCES expenses(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
share_amount REAL NOT NULL,
|
||||
PRIMARY KEY (expense_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settlements (
|
||||
id TEXT PRIMARY KEY,
|
||||
household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE,
|
||||
from_user_id TEXT NOT NULL REFERENCES users(id),
|
||||
to_user_id TEXT NOT NULL REFERENCES users(id),
|
||||
amount REAL NOT NULL,
|
||||
settled_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expenses_household ON expenses(household_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_shares_expense ON expense_shares(expense_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_settlements_household ON settlements(household_id);
|
||||
31
backend/src/index.js
Normal file
31
backend/src/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
|
||||
const { router: authRouter } = require('./routes/auth');
|
||||
const { router: householdsRouter } = require('./routes/households');
|
||||
const { router: categoriesRouter } = require('./routes/categories');
|
||||
const { router: expensesRouter } = require('./routes/expenses');
|
||||
const { router: settlementsRouter } = require('./routes/settlements');
|
||||
const { router: statsRouter } = require('./routes/stats');
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get('/health', (req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
app.use('/auth', authRouter);
|
||||
app.use('/households', householdsRouter);
|
||||
app.use('/categories', categoriesRouter);
|
||||
app.use('/expenses', expensesRouter);
|
||||
app.use('/settlements', settlementsRouter);
|
||||
app.use('/stats', statsRouter);
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Wewnętrzny błąd serwera' });
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => console.log(`Backend listening on port ${PORT}`));
|
||||
36
backend/src/middleware/auth.js
Normal file
36
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../db/db');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET env var is required');
|
||||
}
|
||||
|
||||
function signToken(user) {
|
||||
return jwt.sign({ sub: user.id, email: user.email }, JWT_SECRET, { expiresIn: '30d' });
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || '';
|
||||
const [scheme, token] = header.split(' ');
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
return res.status(401).json({ error: 'Missing bearer token' });
|
||||
}
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, JWT_SECRET);
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
// The JWT signature alone doesn't prove the account still exists (deleted
|
||||
// account, or — in dev — a wiped database): reject it the same way so the
|
||||
// client logs out instead of misreading "no accounts" as "no household".
|
||||
const user = db.prepare('SELECT id FROM users WHERE id = ?').get(payload.sub);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
req.userId = payload.sub;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { signToken, requireAuth, JWT_SECRET };
|
||||
206
backend/src/routes/auth.js
Normal file
206
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,206 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const { db } = require('../db/db');
|
||||
const { signToken, requireAuth } = require('../middleware/auth');
|
||||
const { sendMail } = require('../utils/mailer');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const RESET_TOKEN_TTL_MS = 60 * 60 * 1000;
|
||||
const FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:8856';
|
||||
|
||||
function toPublicUser(user) {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
avatarEmoji: user.avatar_emoji,
|
||||
emailNotifications: !!user.email_notifications,
|
||||
};
|
||||
}
|
||||
|
||||
router.post('/register', (req, res) => {
|
||||
const { email, password, name } = req.body || {};
|
||||
if (!email || !password || !name) {
|
||||
return res.status(400).json({ error: 'email, password i name są wymagane' });
|
||||
}
|
||||
if (String(password).length < 6) {
|
||||
return res.status(400).json({ error: 'Hasło musi mieć co najmniej 6 znaków' });
|
||||
}
|
||||
|
||||
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Konto z tym adresem e-mail już istnieje' });
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: uuid(),
|
||||
email: email.toLowerCase(),
|
||||
password_hash: bcrypt.hashSync(password, 10),
|
||||
name,
|
||||
};
|
||||
db.prepare('INSERT INTO users (id, email, password_hash, name) VALUES (?, ?, ?, ?)').run(
|
||||
user.id,
|
||||
user.email,
|
||||
user.password_hash,
|
||||
user.name
|
||||
);
|
||||
|
||||
const created = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
|
||||
const token = signToken(created);
|
||||
res.status(201).json({ token, user: toPublicUser(created) });
|
||||
});
|
||||
|
||||
router.post('/login', (req, res) => {
|
||||
const { email, password } = req.body || {};
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'email i password są wymagane' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Nieprawidłowy e-mail lub hasło' });
|
||||
}
|
||||
|
||||
const token = signToken(user);
|
||||
res.json({ token, user: toPublicUser(user) });
|
||||
});
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
|
||||
if (!user) return res.status(404).json({ error: 'Użytkownik nie znaleziony' });
|
||||
res.json({ user: toPublicUser(user) });
|
||||
});
|
||||
|
||||
router.put('/me', requireAuth, (req, res) => {
|
||||
const { name } = req.body || {};
|
||||
if (!name || !name.trim()) {
|
||||
return res.status(400).json({ error: 'name jest wymagane' });
|
||||
}
|
||||
db.prepare('UPDATE users SET name = ? WHERE id = ?').run(name.trim(), req.userId);
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
|
||||
res.json({ user: toPublicUser(user) });
|
||||
});
|
||||
|
||||
router.delete('/me', requireAuth, (req, res) => {
|
||||
const userId = req.userId;
|
||||
const householdIds = db
|
||||
.prepare('SELECT household_id FROM household_members WHERE user_id = ?')
|
||||
.all(userId)
|
||||
.map((r) => r.household_id);
|
||||
|
||||
try {
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
return res.json({ ok: true, anonymized: false });
|
||||
} catch (err) {
|
||||
// Foreign key constraint: user has expense/settlement history shared with others.
|
||||
// Anonymize instead of a hard delete so their household's financial history stays intact.
|
||||
}
|
||||
|
||||
const anonymize = db.transaction(() => {
|
||||
db.prepare('DELETE FROM household_members WHERE user_id = ?').run(userId);
|
||||
for (const householdId of householdIds) {
|
||||
const remaining = db
|
||||
.prepare('SELECT COUNT(*) AS c FROM household_members WHERE household_id = ?')
|
||||
.get(householdId).c;
|
||||
if (remaining === 0) {
|
||||
db.prepare('DELETE FROM households WHERE id = ?').run(householdId);
|
||||
}
|
||||
}
|
||||
db.prepare(
|
||||
`UPDATE users SET name = 'Usunięte konto', email = ?, password_hash = '', email_notifications = 0 WHERE id = ?`
|
||||
).run(`deleted-${userId}@ktoco.invalid`, userId);
|
||||
});
|
||||
anonymize();
|
||||
|
||||
res.json({ ok: true, anonymized: true });
|
||||
});
|
||||
|
||||
router.put('/me/notifications', requireAuth, (req, res) => {
|
||||
const { enabled } = req.body || {};
|
||||
db.prepare('UPDATE users SET email_notifications = ? WHERE id = ?').run(enabled ? 1 : 0, req.userId);
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
|
||||
res.json({ user: toPublicUser(user) });
|
||||
});
|
||||
|
||||
router.post('/change-password', requireAuth, (req, res) => {
|
||||
const { currentPassword, newPassword } = req.body || {};
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'currentPassword i newPassword są wymagane' });
|
||||
}
|
||||
if (String(newPassword).length < 6) {
|
||||
return res.status(400).json({ error: 'Nowe hasło musi mieć co najmniej 6 znaków' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.userId);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||
// 400, not 401: the JWT is valid (requireAuth already passed) — this is a form
|
||||
// validation failure, not an auth failure, and must not trigger a global session logout.
|
||||
return res.status(400).json({ error: 'Bieżące hasło jest nieprawidłowe' });
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(newPassword, 10);
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, user.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/forgot-password', async (req, res) => {
|
||||
const { email } = req.body || {};
|
||||
const genericResponse = { message: 'Jeśli konto istnieje, wysłaliśmy e-mail z linkiem do resetu hasła' };
|
||||
if (!email) {
|
||||
return res.status(400).json({ error: 'email jest wymagany' });
|
||||
}
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
if (!user) {
|
||||
return res.json(genericResponse);
|
||||
}
|
||||
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MS).toISOString();
|
||||
db.prepare('INSERT INTO password_resets (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)').run(
|
||||
uuid(),
|
||||
user.id,
|
||||
token,
|
||||
expiresAt
|
||||
);
|
||||
|
||||
const resetLink = `${FRONTEND_URL}/reset-password?token=${token}`;
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: 'KtoCo — reset hasła',
|
||||
text: `Cześć ${user.name},\n\nAby zresetować hasło, kliknij poniższy link (ważny 1 godzinę):\n${resetLink}\n\nJeśli to nie Ty, zignoruj tę wiadomość.`,
|
||||
});
|
||||
|
||||
res.json(genericResponse);
|
||||
});
|
||||
|
||||
router.post('/reset-password', (req, res) => {
|
||||
const { token, password } = req.body || {};
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({ error: 'token i password są wymagane' });
|
||||
}
|
||||
if (String(password).length < 6) {
|
||||
return res.status(400).json({ error: 'Hasło musi mieć co najmniej 6 znaków' });
|
||||
}
|
||||
|
||||
const reset = db
|
||||
.prepare(`SELECT * FROM password_resets WHERE token = ? AND used_at IS NULL AND expires_at > datetime('now')`)
|
||||
.get(token);
|
||||
if (!reset) {
|
||||
return res.status(400).json({ error: 'Link do resetu hasła jest nieprawidłowy lub wygasł' });
|
||||
}
|
||||
|
||||
const passwordHash = bcrypt.hashSync(password, 10);
|
||||
const apply = db.transaction(() => {
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(passwordHash, reset.user_id);
|
||||
db.prepare(`UPDATE password_resets SET used_at = datetime('now') WHERE id = ?`).run(reset.id);
|
||||
});
|
||||
apply();
|
||||
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = { router, toPublicUser };
|
||||
66
backend/src/routes/categories.js
Normal file
66
backend/src/routes/categories.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const express = require('express');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const { db } = require('../db/db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireHousehold } = require('../utils/households');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireHousehold);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const categories = db
|
||||
.prepare('SELECT * FROM categories WHERE household_id = ? ORDER BY name')
|
||||
.all(req.household.id);
|
||||
res.json({ categories });
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name, icon, color } = req.body || {};
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Nazwa kategorii jest wymagana' });
|
||||
}
|
||||
const category = {
|
||||
id: uuid(),
|
||||
household_id: req.household.id,
|
||||
name,
|
||||
icon: icon || '📦',
|
||||
color: color || '#6b7280',
|
||||
};
|
||||
db.prepare('INSERT INTO categories (id, household_id, name, icon, color) VALUES (?, ?, ?, ?, ?)').run(
|
||||
category.id,
|
||||
category.household_id,
|
||||
category.name,
|
||||
category.icon,
|
||||
category.color
|
||||
);
|
||||
res.status(201).json({ category });
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
const existing = db
|
||||
.prepare('SELECT * FROM categories WHERE id = ? AND household_id = ?')
|
||||
.get(req.params.id, req.household.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Kategoria nie znaleziona' });
|
||||
}
|
||||
const { name, icon, color } = req.body || {};
|
||||
db.prepare('UPDATE categories SET name = ?, icon = ?, color = ? WHERE id = ?').run(
|
||||
name ?? existing.name,
|
||||
icon ?? existing.icon,
|
||||
color ?? existing.color,
|
||||
existing.id
|
||||
);
|
||||
res.json({ category: db.prepare('SELECT * FROM categories WHERE id = ?').get(existing.id) });
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const result = db
|
||||
.prepare('DELETE FROM categories WHERE id = ? AND household_id = ?')
|
||||
.run(req.params.id, req.household.id);
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Kategoria nie znaleziona' });
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
214
backend/src/routes/expenses.js
Normal file
214
backend/src/routes/expenses.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const express = require('express');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const { db } = require('../db/db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireHousehold, getMembers } = require('../utils/households');
|
||||
const { sendMail } = require('../utils/mailer');
|
||||
const { round2 } = require('../utils/balance');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireHousehold);
|
||||
|
||||
const EPSILON = 0.01;
|
||||
|
||||
function computeShares(splitType, amount, payerId, members, shares) {
|
||||
const memberIds = members.map((m) => m.id);
|
||||
if (splitType === 'equal') {
|
||||
if (memberIds.length === 0) {
|
||||
throw new Error('Gospodarstwo domowe nie ma członków do podziału wydatku');
|
||||
}
|
||||
const base = Math.floor((amount / memberIds.length) * 100) / 100;
|
||||
const shares = Object.fromEntries(memberIds.map((id) => [id, base]));
|
||||
const distributed = round2(base * memberIds.length);
|
||||
const remainder = round2(amount - distributed);
|
||||
// any leftover cents from rounding go to the last member so the split always sums exactly to `amount`
|
||||
shares[memberIds[memberIds.length - 1]] = round2(base + remainder);
|
||||
return shares;
|
||||
}
|
||||
|
||||
if (splitType === 'exact' || splitType === 'full') {
|
||||
if (!shares || typeof shares !== 'object') {
|
||||
throw new Error('Pole "shares" jest wymagane dla wybranego typu podziału');
|
||||
}
|
||||
const sum = Object.values(shares).reduce((acc, v) => acc + Number(v), 0);
|
||||
if (Math.abs(sum - amount) > EPSILON) {
|
||||
throw new Error('Suma udziałów musi być równa kwocie wydatku');
|
||||
}
|
||||
for (const userId of Object.keys(shares)) {
|
||||
if (!memberIds.includes(userId)) {
|
||||
throw new Error('Udział przypisany do osoby spoza gospodarstwa domowego');
|
||||
}
|
||||
}
|
||||
return shares;
|
||||
}
|
||||
|
||||
throw new Error('Nieznany typ podziału');
|
||||
}
|
||||
|
||||
function attachShares(expense) {
|
||||
const shares = db
|
||||
.prepare('SELECT user_id, share_amount FROM expense_shares WHERE expense_id = ?')
|
||||
.all(expense.id);
|
||||
return { ...expense, shares };
|
||||
}
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const { month, categoryId, payerId } = req.query;
|
||||
let query = 'SELECT * FROM expenses WHERE household_id = ?';
|
||||
const params = [req.household.id];
|
||||
|
||||
if (month) {
|
||||
query += " AND strftime('%Y-%m', expense_date) = ?";
|
||||
params.push(month);
|
||||
}
|
||||
if (categoryId) {
|
||||
query += ' AND category_id = ?';
|
||||
params.push(categoryId);
|
||||
}
|
||||
if (payerId) {
|
||||
query += ' AND payer_id = ?';
|
||||
params.push(payerId);
|
||||
}
|
||||
query += ' ORDER BY expense_date DESC, created_at DESC';
|
||||
|
||||
const expenses = db.prepare(query).all(...params).map(attachShares);
|
||||
res.json({ expenses });
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { amount, title, categoryId, expenseDate, payerId, splitType, shares } = req.body || {};
|
||||
if (!amount || !title || !expenseDate || !payerId || !splitType) {
|
||||
return res.status(400).json({ error: 'amount, title, expenseDate, payerId i splitType są wymagane' });
|
||||
}
|
||||
|
||||
const members = getMembers(req.household.id);
|
||||
if (!members.some((m) => m.id === payerId)) {
|
||||
return res.status(400).json({ error: 'payerId musi być członkiem gospodarstwa domowego' });
|
||||
}
|
||||
|
||||
let computedShares;
|
||||
try {
|
||||
computedShares = computeShares(splitType, Number(amount), payerId, members, shares);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const expense = {
|
||||
id: uuid(),
|
||||
household_id: req.household.id,
|
||||
payer_id: payerId,
|
||||
amount: Number(amount),
|
||||
title,
|
||||
category_id: categoryId || null,
|
||||
expense_date: expenseDate,
|
||||
split_type: splitType,
|
||||
};
|
||||
|
||||
const insert = db.transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO expenses (id, household_id, payer_id, amount, title, category_id, expense_date, split_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
expense.id,
|
||||
expense.household_id,
|
||||
expense.payer_id,
|
||||
expense.amount,
|
||||
expense.title,
|
||||
expense.category_id,
|
||||
expense.expense_date,
|
||||
expense.split_type
|
||||
);
|
||||
for (const [userId, shareAmount] of Object.entries(computedShares)) {
|
||||
db.prepare('INSERT INTO expense_shares (expense_id, user_id, share_amount) VALUES (?, ?, ?)').run(
|
||||
expense.id,
|
||||
userId,
|
||||
shareAmount
|
||||
);
|
||||
}
|
||||
});
|
||||
insert();
|
||||
|
||||
const actor = db.prepare('SELECT name FROM users WHERE id = ?').get(req.userId);
|
||||
const notifyTargets = db
|
||||
.prepare(
|
||||
`SELECT u.email, u.name FROM users u
|
||||
JOIN household_members hm ON hm.user_id = u.id
|
||||
WHERE hm.household_id = ? AND u.id != ? AND u.email_notifications = 1`
|
||||
)
|
||||
.all(req.household.id, req.userId);
|
||||
for (const target of notifyTargets) {
|
||||
sendMail({
|
||||
to: target.email,
|
||||
subject: 'KtoCo — nowy wydatek',
|
||||
text: `${actor?.name || 'Ktoś'} dodał(a) nowy wydatek "${expense.title}" na kwotę ${expense.amount.toFixed(2)}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const created = db.prepare('SELECT * FROM expenses WHERE id = ?').get(expense.id);
|
||||
res.status(201).json({ expense: attachShares(created) });
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
const existing = db
|
||||
.prepare('SELECT * FROM expenses WHERE id = ? AND household_id = ?')
|
||||
.get(req.params.id, req.household.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Wydatek nie znaleziony' });
|
||||
}
|
||||
|
||||
const { amount, title, categoryId, expenseDate, payerId, splitType, shares } = req.body || {};
|
||||
const members = getMembers(req.household.id);
|
||||
const finalAmount = amount !== undefined ? Number(amount) : existing.amount;
|
||||
const finalPayerId = payerId || existing.payer_id;
|
||||
const finalSplitType = splitType || existing.split_type;
|
||||
|
||||
if (!members.some((m) => m.id === finalPayerId)) {
|
||||
return res.status(400).json({ error: 'payerId musi być członkiem gospodarstwa domowego' });
|
||||
}
|
||||
|
||||
let computedShares;
|
||||
try {
|
||||
computedShares = computeShares(finalSplitType, finalAmount, finalPayerId, members, shares);
|
||||
} catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const update = db.transaction(() => {
|
||||
db.prepare(
|
||||
`UPDATE expenses SET amount = ?, title = ?, category_id = ?, expense_date = ?, payer_id = ?,
|
||||
split_type = ?, updated_at = datetime('now') WHERE id = ?`
|
||||
).run(
|
||||
finalAmount,
|
||||
title ?? existing.title,
|
||||
categoryId !== undefined ? categoryId : existing.category_id,
|
||||
expenseDate ?? existing.expense_date,
|
||||
finalPayerId,
|
||||
finalSplitType,
|
||||
existing.id
|
||||
);
|
||||
db.prepare('DELETE FROM expense_shares WHERE expense_id = ?').run(existing.id);
|
||||
for (const [userId, shareAmount] of Object.entries(computedShares)) {
|
||||
db.prepare('INSERT INTO expense_shares (expense_id, user_id, share_amount) VALUES (?, ?, ?)').run(
|
||||
existing.id,
|
||||
userId,
|
||||
shareAmount
|
||||
);
|
||||
}
|
||||
});
|
||||
update();
|
||||
|
||||
const updated = db.prepare('SELECT * FROM expenses WHERE id = ?').get(existing.id);
|
||||
res.json({ expense: attachShares(updated) });
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const result = db
|
||||
.prepare('DELETE FROM expenses WHERE id = ? AND household_id = ?')
|
||||
.run(req.params.id, req.household.id);
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Wydatek nie znaleziony' });
|
||||
}
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
154
backend/src/routes/households.js
Normal file
154
backend/src/routes/households.js
Normal file
@@ -0,0 +1,154 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const { db, DEFAULT_CATEGORIES } = require('../db/db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { getHouseholdsForUser, getHouseholdById, isMember, getMembers } = require('../utils/households');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function generateCode() {
|
||||
return crypto.randomBytes(4).toString('hex').toUpperCase();
|
||||
}
|
||||
|
||||
function serializeHousehold(household) {
|
||||
const members = getMembers(household.id);
|
||||
const invite = db
|
||||
.prepare(
|
||||
`SELECT * FROM invites WHERE household_id = ? AND used_at IS NULL AND expires_at > datetime('now')
|
||||
ORDER BY expires_at DESC LIMIT 1`
|
||||
)
|
||||
.get(household.id);
|
||||
return { ...household, members, inviteCode: invite?.code || null };
|
||||
}
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const households = getHouseholdsForUser(req.userId).map(serializeHousehold);
|
||||
res.json({ households });
|
||||
});
|
||||
|
||||
router.get('/:id', (req, res) => {
|
||||
if (!isMember(req.params.id, req.userId)) {
|
||||
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
|
||||
}
|
||||
const household = getHouseholdById(req.params.id);
|
||||
if (!household) return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
|
||||
res.json({ household: serializeHousehold(household) });
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { name, currency } = req.body || {};
|
||||
const household = { id: uuid(), name: name || 'Nasze gospodarstwo', currency: currency || 'PLN' };
|
||||
|
||||
const createHousehold = db.transaction(() => {
|
||||
db.prepare('INSERT INTO households (id, name, currency) VALUES (?, ?, ?)').run(
|
||||
household.id,
|
||||
household.name,
|
||||
household.currency
|
||||
);
|
||||
db.prepare('INSERT INTO household_members (household_id, user_id) VALUES (?, ?)').run(household.id, req.userId);
|
||||
for (const cat of DEFAULT_CATEGORIES) {
|
||||
db.prepare('INSERT INTO categories (id, household_id, name, icon, color) VALUES (?, ?, ?, ?, ?)').run(
|
||||
uuid(),
|
||||
household.id,
|
||||
cat.name,
|
||||
cat.icon,
|
||||
cat.color
|
||||
);
|
||||
}
|
||||
});
|
||||
createHousehold();
|
||||
|
||||
const code = generateCode();
|
||||
const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO invites (id, household_id, code, created_by, expires_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(uuid(), household.id, code, req.userId, expiresAt);
|
||||
|
||||
res.status(201).json({ household: serializeHousehold(getHouseholdById(household.id)) });
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
if (!isMember(req.params.id, req.userId)) {
|
||||
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
|
||||
}
|
||||
const household = getHouseholdById(req.params.id);
|
||||
if (!household) return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
|
||||
|
||||
const { name, currency } = req.body || {};
|
||||
db.prepare('UPDATE households SET name = ?, currency = ? WHERE id = ?').run(
|
||||
name ?? household.name,
|
||||
currency ?? household.currency,
|
||||
household.id
|
||||
);
|
||||
res.json({ household: serializeHousehold(getHouseholdById(household.id)) });
|
||||
});
|
||||
|
||||
router.post('/:id/invite', (req, res) => {
|
||||
if (!isMember(req.params.id, req.userId)) {
|
||||
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
|
||||
}
|
||||
db.prepare(`UPDATE invites SET used_at = datetime('now') WHERE household_id = ? AND used_at IS NULL`).run(
|
||||
req.params.id
|
||||
);
|
||||
const code = generateCode();
|
||||
const expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString();
|
||||
db.prepare(
|
||||
'INSERT INTO invites (id, household_id, code, created_by, expires_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(uuid(), req.params.id, code, req.userId, expiresAt);
|
||||
|
||||
res.status(201).json({ inviteCode: code });
|
||||
});
|
||||
|
||||
router.post('/join', (req, res) => {
|
||||
const { code } = req.body || {};
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Kod zaproszenia jest wymagany' });
|
||||
}
|
||||
|
||||
const invite = db
|
||||
.prepare(`SELECT * FROM invites WHERE code = ? AND used_at IS NULL AND expires_at > datetime('now')`)
|
||||
.get(code.toUpperCase());
|
||||
if (!invite) {
|
||||
return res.status(404).json({ error: 'Kod zaproszenia jest nieprawidłowy lub wygasł' });
|
||||
}
|
||||
if (isMember(invite.household_id, req.userId)) {
|
||||
return res.status(409).json({ error: 'Jesteś już członkiem tego gospodarstwa' });
|
||||
}
|
||||
|
||||
db.prepare('INSERT INTO household_members (household_id, user_id) VALUES (?, ?)').run(
|
||||
invite.household_id,
|
||||
req.userId
|
||||
);
|
||||
|
||||
res.json({ household: serializeHousehold(getHouseholdById(invite.household_id)) });
|
||||
});
|
||||
|
||||
router.delete('/:id/members/:userId', (req, res) => {
|
||||
if (!isMember(req.params.id, req.userId)) {
|
||||
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
|
||||
}
|
||||
db.prepare('DELETE FROM household_members WHERE household_id = ? AND user_id = ?').run(
|
||||
req.params.id,
|
||||
req.params.userId
|
||||
);
|
||||
const remaining = getMembers(req.params.id);
|
||||
if (remaining.length === 0) {
|
||||
db.prepare('DELETE FROM households WHERE id = ?').run(req.params.id);
|
||||
return res.json({ deleted: true });
|
||||
}
|
||||
res.json({ household: serializeHousehold(getHouseholdById(req.params.id)) });
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
if (!isMember(req.params.id, req.userId)) {
|
||||
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
|
||||
}
|
||||
db.prepare('DELETE FROM households WHERE id = ?').run(req.params.id);
|
||||
res.json({ deleted: true });
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
38
backend/src/routes/settlements.js
Normal file
38
backend/src/routes/settlements.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const express = require('express');
|
||||
const { v4: uuid } = require('uuid');
|
||||
const { db } = require('../db/db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireHousehold, getMembers } = require('../utils/households');
|
||||
const { computeSettlement } = require('../utils/balance');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireHousehold);
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
const settlements = db
|
||||
.prepare('SELECT * FROM settlements WHERE household_id = ? ORDER BY settled_at DESC')
|
||||
.all(req.household.id);
|
||||
res.json({ settlements });
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const members = getMembers(req.household.id);
|
||||
const current = computeSettlement(req.household.id, members);
|
||||
|
||||
if (current.settled) {
|
||||
return res.status(409).json({ error: 'Jesteście już rozliczeni' });
|
||||
}
|
||||
|
||||
const insert = db.transaction(() => {
|
||||
for (const tx of current.transactions) {
|
||||
db.prepare(
|
||||
'INSERT INTO settlements (id, household_id, from_user_id, to_user_id, amount) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(uuid(), req.household.id, tx.from, tx.to, tx.amount);
|
||||
}
|
||||
});
|
||||
insert();
|
||||
|
||||
res.status(201).json({ transactions: current.transactions });
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
92
backend/src/routes/stats.js
Normal file
92
backend/src/routes/stats.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const express = require('express');
|
||||
const { db } = require('../db/db');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { requireHousehold, getMembers } = require('../utils/households');
|
||||
const { computeSettlement } = require('../utils/balance');
|
||||
|
||||
const router = express.Router();
|
||||
router.use(requireAuth, requireHousehold);
|
||||
|
||||
function currentMonth() {
|
||||
return new Date().toISOString().slice(0, 7);
|
||||
}
|
||||
|
||||
router.get('/balance', (req, res) => {
|
||||
const members = getMembers(req.household.id);
|
||||
const result = computeSettlement(req.household.id, members);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get('/summary', (req, res) => {
|
||||
const month = req.query.month || currentMonth();
|
||||
const total = db
|
||||
.prepare(
|
||||
`SELECT COALESCE(SUM(amount), 0) AS total FROM expenses
|
||||
WHERE household_id = ? AND strftime('%Y-%m', expense_date) = ?`
|
||||
)
|
||||
.get(req.household.id, month).total;
|
||||
|
||||
const byPayer = db
|
||||
.prepare(
|
||||
`SELECT payer_id AS userId, SUM(amount) AS total FROM expenses
|
||||
WHERE household_id = ? AND strftime('%Y-%m', expense_date) = ? GROUP BY payer_id`
|
||||
)
|
||||
.all(req.household.id, month);
|
||||
|
||||
const byCategory = db
|
||||
.prepare(
|
||||
`SELECT c.id AS categoryId, c.name, c.icon, c.color, SUM(e.amount) AS total
|
||||
FROM expenses e LEFT JOIN categories c ON c.id = e.category_id
|
||||
WHERE e.household_id = ? AND strftime('%Y-%m', e.expense_date) = ?
|
||||
GROUP BY c.id ORDER BY total DESC`
|
||||
)
|
||||
.all(req.household.id, month);
|
||||
|
||||
const byShare = db
|
||||
.prepare(
|
||||
`SELECT es.user_id AS userId, SUM(es.share_amount) AS total
|
||||
FROM expense_shares es JOIN expenses e ON e.id = es.expense_id
|
||||
WHERE e.household_id = ? AND strftime('%Y-%m', e.expense_date) = ?
|
||||
GROUP BY es.user_id`
|
||||
)
|
||||
.all(req.household.id, month);
|
||||
|
||||
res.json({ month, total, byPayer, byShare, byCategory });
|
||||
});
|
||||
|
||||
router.get('/monthly', (req, res) => {
|
||||
const months = db
|
||||
.prepare(
|
||||
`SELECT strftime('%Y-%m', expense_date) AS month, SUM(amount) AS total
|
||||
FROM expenses WHERE household_id = ? GROUP BY month ORDER BY month`
|
||||
)
|
||||
.all(req.household.id);
|
||||
res.json({ months });
|
||||
});
|
||||
|
||||
router.get('/export.csv', (req, res) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT e.expense_date, e.title, e.amount, c.name AS category, u.name AS payer, e.split_type
|
||||
FROM expenses e
|
||||
LEFT JOIN categories c ON c.id = e.category_id
|
||||
JOIN users u ON u.id = e.payer_id
|
||||
WHERE e.household_id = ? ORDER BY e.expense_date DESC`
|
||||
)
|
||||
.all(req.household.id);
|
||||
|
||||
const escape = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
|
||||
const header = ['Data', 'Tytuł', 'Kwota', 'Kategoria', 'Płacił', 'Podział'];
|
||||
const lines = [header.join(',')];
|
||||
for (const r of rows) {
|
||||
lines.push(
|
||||
[escape(r.expense_date), escape(r.title), r.amount, escape(r.category), escape(r.payer), escape(r.split_type)].join(',')
|
||||
);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename="wydatki.csv"');
|
||||
res.send(lines.join('\n'));
|
||||
});
|
||||
|
||||
module.exports = { router };
|
||||
80
backend/src/utils/balance.js
Normal file
80
backend/src/utils/balance.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const { db } = require('../db/db');
|
||||
|
||||
function round2(n) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
// Positive net means others owe this user money; negative means this user owes others.
|
||||
function computeNetBalances(householdId, memberIds) {
|
||||
const net = Object.fromEntries(memberIds.map((id) => [id, 0]));
|
||||
|
||||
const paid = db
|
||||
.prepare(
|
||||
`SELECT payer_id AS user_id, SUM(amount) AS total FROM expenses
|
||||
WHERE household_id = ? GROUP BY payer_id`
|
||||
)
|
||||
.all(householdId);
|
||||
for (const row of paid) {
|
||||
net[row.user_id] = (net[row.user_id] || 0) + row.total;
|
||||
}
|
||||
|
||||
const owed = db
|
||||
.prepare(
|
||||
`SELECT es.user_id AS user_id, SUM(es.share_amount) AS total FROM expense_shares es
|
||||
JOIN expenses e ON e.id = es.expense_id
|
||||
WHERE e.household_id = ? GROUP BY es.user_id`
|
||||
)
|
||||
.all(householdId);
|
||||
for (const row of owed) {
|
||||
net[row.user_id] = (net[row.user_id] || 0) - row.total;
|
||||
}
|
||||
|
||||
const settlements = db
|
||||
.prepare('SELECT from_user_id, to_user_id, amount FROM settlements WHERE household_id = ?')
|
||||
.all(householdId);
|
||||
for (const s of settlements) {
|
||||
net[s.from_user_id] = (net[s.from_user_id] || 0) + s.amount;
|
||||
net[s.to_user_id] = (net[s.to_user_id] || 0) - s.amount;
|
||||
}
|
||||
|
||||
for (const id of Object.keys(net)) {
|
||||
net[id] = round2(net[id]);
|
||||
}
|
||||
return net;
|
||||
}
|
||||
|
||||
// Greedy debt simplification: matches debtors to creditors to produce a minimal
|
||||
// set of "who pays whom how much" transactions that settle every balance to zero.
|
||||
function simplifyDebts(net) {
|
||||
const creditors = [];
|
||||
const debtors = [];
|
||||
for (const [id, amount] of Object.entries(net)) {
|
||||
if (amount > 0.01) creditors.push({ id, amount });
|
||||
else if (amount < -0.01) debtors.push({ id, amount: -amount });
|
||||
}
|
||||
creditors.sort((a, b) => b.amount - a.amount);
|
||||
debtors.sort((a, b) => b.amount - a.amount);
|
||||
|
||||
const transactions = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < debtors.length && j < creditors.length) {
|
||||
const pay = Math.min(debtors[i].amount, creditors[j].amount);
|
||||
transactions.push({ from: debtors[i].id, to: creditors[j].id, amount: round2(pay) });
|
||||
debtors[i].amount = round2(debtors[i].amount - pay);
|
||||
creditors[j].amount = round2(creditors[j].amount - pay);
|
||||
if (debtors[i].amount < 0.01) i++;
|
||||
if (creditors[j].amount < 0.01) j++;
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
|
||||
// Returns { settled: bool, transactions: [{from, to, amount}], net }
|
||||
function computeSettlement(householdId, members) {
|
||||
const memberIds = members.map((m) => m.id);
|
||||
const net = computeNetBalances(householdId, memberIds);
|
||||
const transactions = simplifyDebts(net);
|
||||
return { settled: transactions.length === 0, transactions, net };
|
||||
}
|
||||
|
||||
module.exports = { computeNetBalances, computeSettlement, simplifyDebts, round2 };
|
||||
51
backend/src/utils/households.js
Normal file
51
backend/src/utils/households.js
Normal file
@@ -0,0 +1,51 @@
|
||||
const { db } = require('../db/db');
|
||||
|
||||
function getHouseholdsForUser(userId) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT h.* FROM households h
|
||||
JOIN household_members hm ON hm.household_id = h.id
|
||||
WHERE hm.user_id = ?
|
||||
ORDER BY h.created_at`
|
||||
)
|
||||
.all(userId);
|
||||
}
|
||||
|
||||
function getHouseholdById(householdId) {
|
||||
return db.prepare('SELECT * FROM households WHERE id = ?').get(householdId);
|
||||
}
|
||||
|
||||
function isMember(householdId, userId) {
|
||||
return !!db
|
||||
.prepare('SELECT 1 FROM household_members WHERE household_id = ? AND user_id = ?')
|
||||
.get(householdId, userId);
|
||||
}
|
||||
|
||||
function getMembers(householdId) {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT u.id, u.name, u.email, u.avatar_emoji FROM users u
|
||||
JOIN household_members hm ON hm.user_id = u.id
|
||||
WHERE hm.household_id = ?
|
||||
ORDER BY hm.joined_at`
|
||||
)
|
||||
.all(householdId);
|
||||
}
|
||||
|
||||
function requireHousehold(req, res, next) {
|
||||
const householdId = req.headers['x-household-id'];
|
||||
if (!householdId) {
|
||||
return res.status(400).json({ error: 'Nie wybrano gospodarstwa (brak nagłówka X-Household-Id)' });
|
||||
}
|
||||
if (!isMember(householdId, req.userId)) {
|
||||
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
|
||||
}
|
||||
const household = getHouseholdById(householdId);
|
||||
if (!household) {
|
||||
return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
|
||||
}
|
||||
req.household = household;
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { getHouseholdsForUser, getHouseholdById, isMember, getMembers, requireHousehold };
|
||||
26
backend/src/utils/mailer.js
Normal file
26
backend/src/utils/mailer.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
const { SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASS, SMTP_FROM, SMTP_SECURE } = process.env;
|
||||
|
||||
const transporter = SMTP_HOST
|
||||
? nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: Number(SMTP_PORT) || 587,
|
||||
secure: SMTP_SECURE === 'true',
|
||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
||||
})
|
||||
: null;
|
||||
|
||||
async function sendMail({ to, subject, text }) {
|
||||
if (!transporter) {
|
||||
console.log(`[mailer] SMTP nieskonfigurowane — pomijam wysyłkę do ${to}: "${subject}"`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await transporter.sendMail({ from: SMTP_FROM || SMTP_USER, to, subject, text });
|
||||
} catch (err) {
|
||||
console.error('[mailer] Błąd wysyłki e-mail:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { sendMail, isConfigured: !!transporter };
|
||||
Reference in New Issue
Block a user