This commit is contained in:
2026-07-10 22:57:16 +02:00
commit c4a692b5d2
66 changed files with 4666 additions and 0 deletions

3
backend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
data
.git

18
backend/Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM node:20-bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package.json ./
RUN npm install --omit=dev
COPY src ./src
ENV NODE_ENV=production
ENV DATABASE_PATH=/app/data/app.db
EXPOSE 3000
CMD ["node", "src/index.js"]

20
backend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "ktoco-backend",
"version": "1.0.0",
"private": true,
"type": "commonjs",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^6.9.14",
"uuid": "^9.0.1"
}
}

25
backend/src/db/db.js Normal file
View 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
View 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
View 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}`));

View 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
View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };