add multi lang
This commit is contained in:
2026-07-11 20:03:15 +02:00
parent f0813894a8
commit ae3b435b51
116 changed files with 1543 additions and 484 deletions

View File

@@ -12,14 +12,14 @@ 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' },
];
// `CREATE TABLE IF NOT EXISTS` (above) only creates the table on a fresh database — it
// can't add a column to a `users` table that already exists from before this column was
// introduced. Add it here so upgrading an existing deployment doesn't require a manual
// migration, and existing users/rows are left untouched (ALTER TABLE ADD COLUMN only adds
// the new column with its default; it does not delete or modify any existing data).
const userColumns = db.prepare('PRAGMA table_info(users)').all();
if (!userColumns.some((c) => c.name === 'language')) {
db.exec(`ALTER TABLE users ADD COLUMN language TEXT NOT NULL DEFAULT 'pl'`);
}
module.exports = { db, DEFAULT_CATEGORIES };
module.exports = { db };

View File

@@ -5,6 +5,7 @@ CREATE TABLE IF NOT EXISTS users (
name TEXT NOT NULL,
avatar_emoji TEXT NOT NULL DEFAULT '🙂',
email_notifications INTEGER NOT NULL DEFAULT 1,
language TEXT NOT NULL DEFAULT 'pl',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);

84
backend/src/i18n.js Normal file
View File

@@ -0,0 +1,84 @@
// Backend-side translations: API error codes (translated client-side, see
// frontend/src/i18n/locales/*/errors.json), plus content the backend renders itself
// (transactional emails, CSV export, default category seed) where translation must
// happen server-side because the client isn't in the loop.
//
// To add a language: add a key to SUPPORTED_LANGUAGES and fill in EMAIL_TEMPLATES,
// DEFAULT_CATEGORIES and CSV_COLUMNS below for that language code.
const SUPPORTED_LANGUAGES = ['pl', 'en'];
const DEFAULT_LANGUAGE = 'pl';
function normalizeLanguage(lang) {
return SUPPORTED_LANGUAGES.includes(lang) ? lang : DEFAULT_LANGUAGE;
}
const DEFAULT_CATEGORIES = {
pl: [
{ 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' },
],
en: [
{ name: 'Groceries', icon: 'shopping_cart', color: '#22c55e' },
{ name: 'Housing', icon: 'home', color: '#3b82f6' },
{ name: 'Bills', icon: 'bolt', color: '#f59e0b' },
{ name: 'Transport', icon: 'directions_car', color: '#8b5cf6' },
{ name: 'Dining out', icon: 'restaurant', color: '#ef4444' },
{ name: 'Entertainment', icon: 'celebration', color: '#ec4899' },
{ name: 'Other', icon: 'inventory_2', color: '#6b7280' },
],
};
function getDefaultCategories(lang) {
return DEFAULT_CATEGORIES[normalizeLanguage(lang)];
}
const EMAIL_TEMPLATES = {
pl: {
resetPassword: ({ name, resetLink }) => ({
subject: 'WhoWhat — reset hasła',
text: `Cześć ${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ść.`,
}),
newExpense: ({ actorName, title, amount }) => ({
subject: 'WhoWhat — nowy wydatek',
text: `${actorName} dodał(a) nowy wydatek "${title}" na kwotę ${amount}.`,
}),
},
en: {
resetPassword: ({ name, resetLink }) => ({
subject: 'WhoWhat — password reset',
text: `Hi ${name},\n\nTo reset your password, click the link below (valid for 1 hour):\n${resetLink}\n\nIf this wasn't you, just ignore this email.`,
}),
newExpense: ({ actorName, title, amount }) => ({
subject: 'WhoWhat — new expense',
text: `${actorName} added a new expense "${title}" for ${amount}.`,
}),
},
};
function getEmailTemplates(lang) {
return EMAIL_TEMPLATES[normalizeLanguage(lang)];
}
const CSV_EXPORT = {
pl: { header: ['Data', 'Tytuł', 'Kwota', 'Kategoria', 'Płacił', 'Podział'], filename: 'wydatki.csv' },
en: { header: ['Date', 'Title', 'Amount', 'Category', 'Paid by', 'Split'], filename: 'expenses.csv' },
};
function getCsvExport(lang) {
return CSV_EXPORT[normalizeLanguage(lang)];
}
module.exports = {
SUPPORTED_LANGUAGES,
DEFAULT_LANGUAGE,
normalizeLanguage,
getDefaultCategories,
getEmailTemplates,
getCsvExport,
};

View File

@@ -24,7 +24,7 @@ app.use('/stats', statsRouter);
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: 'Wewnętrzny błąd serwera' });
res.status(500).json({ error: 'internal_server_error' });
});
const PORT = process.env.PORT || 3000;

View File

@@ -14,20 +14,20 @@ 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' });
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' });
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' });
return res.status(401).json({ error: 'invalid_or_expired_token' });
}
req.userId = payload.sub;
next();

View File

@@ -5,6 +5,7 @@ const { v4: uuid } = require('uuid');
const { db } = require('../db/db');
const { signToken, requireAuth } = require('../middleware/auth');
const { sendMail } = require('../utils/mailer');
const { normalizeLanguage, getEmailTemplates } = require('../i18n');
const router = express.Router();
@@ -18,21 +19,22 @@ function toPublicUser(user) {
name: user.name,
avatarEmoji: user.avatar_emoji,
emailNotifications: !!user.email_notifications,
language: user.language,
};
}
router.post('/register', (req, res) => {
const { email, password, name } = req.body || {};
const { email, password, name, language } = req.body || {};
if (!email || !password || !name) {
return res.status(400).json({ error: 'email, password i name są wymagane' });
return res.status(400).json({ error: 'missing_registration_fields' });
}
if (String(password).length < 6) {
return res.status(400).json({ error: 'Hasło musi mieć co najmniej 6 znaków' });
return res.status(400).json({ error: 'password_too_short' });
}
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' });
return res.status(409).json({ error: 'email_already_registered' });
}
const user = {
@@ -40,12 +42,14 @@ router.post('/register', (req, res) => {
email: email.toLowerCase(),
password_hash: bcrypt.hashSync(password, 10),
name,
language: normalizeLanguage(language),
};
db.prepare('INSERT INTO users (id, email, password_hash, name) VALUES (?, ?, ?, ?)').run(
db.prepare('INSERT INTO users (id, email, password_hash, name, language) VALUES (?, ?, ?, ?, ?)').run(
user.id,
user.email,
user.password_hash,
user.name
user.name,
user.language
);
const created = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
@@ -56,12 +60,12 @@ router.post('/register', (req, res) => {
router.post('/login', (req, res) => {
const { email, password } = req.body || {};
if (!email || !password) {
return res.status(400).json({ error: 'email i password są wymagane' });
return res.status(400).json({ error: 'missing_login_fields' });
}
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' });
return res.status(401).json({ error: 'invalid_credentials' });
}
const token = signToken(user);
@@ -70,14 +74,14 @@ router.post('/login', (req, res) => {
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' });
if (!user) return res.status(404).json({ error: 'user_not_found' });
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' });
return res.status(400).json({ error: 'name_required' });
}
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);
@@ -109,9 +113,11 @@ router.delete('/me', requireAuth, (req, res) => {
db.prepare('DELETE FROM households WHERE id = ?').run(householdId);
}
}
// Not translated: this is a placeholder value stored permanently in the database and
// shown to every remaining household member regardless of their own language setting.
db.prepare(
`UPDATE users SET name = 'Usunięte konto', email = ?, password_hash = '', email_notifications = 0 WHERE id = ?`
).run(`deleted-${userId}@ktoco.invalid`, userId);
`UPDATE users SET name = 'Deleted account', email = ?, password_hash = '', email_notifications = 0 WHERE id = ?`
).run(`deleted-${userId}@whowhat.invalid`, userId);
});
anonymize();
@@ -125,20 +131,27 @@ router.put('/me/notifications', requireAuth, (req, res) => {
res.json({ user: toPublicUser(user) });
});
router.put('/me/language', requireAuth, (req, res) => {
const { language } = req.body || {};
db.prepare('UPDATE users SET language = ? WHERE id = ?').run(normalizeLanguage(language), 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' });
return res.status(400).json({ error: 'missing_password_fields' });
}
if (String(newPassword).length < 6) {
return res.status(400).json({ error: 'Nowe hasło musi mieć co najmniej 6 znaków' });
return res.status(400).json({ error: 'password_too_short' });
}
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' });
return res.status(400).json({ error: 'current_password_incorrect' });
}
const passwordHash = bcrypt.hashSync(newPassword, 10);
@@ -148,9 +161,9 @@ router.post('/change-password', requireAuth, (req, res) => {
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' };
const genericResponse = { message: 'If an account exists, a password reset email has been sent' };
if (!email) {
return res.status(400).json({ error: 'email jest wymagany' });
return res.status(400).json({ error: 'email_required' });
}
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
@@ -168,11 +181,8 @@ router.post('/forgot-password', async (req, res) => {
);
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ść.`,
});
const { subject, text } = getEmailTemplates(user.language).resetPassword({ name: user.name, resetLink });
await sendMail({ to: user.email, subject, text });
res.json(genericResponse);
});
@@ -180,17 +190,17 @@ router.post('/forgot-password', async (req, res) => {
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' });
return res.status(400).json({ error: 'missing_reset_fields' });
}
if (String(password).length < 6) {
return res.status(400).json({ error: 'Hasło musi mieć co najmniej 6 znaków' });
return res.status(400).json({ error: 'password_too_short' });
}
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ł' });
return res.status(400).json({ error: 'reset_link_invalid_or_expired' });
}
const passwordHash = bcrypt.hashSync(password, 10);

View File

@@ -17,7 +17,7 @@ router.get('/', (req, res) => {
router.post('/', (req, res) => {
const { name, icon, color } = req.body || {};
if (!name) {
return res.status(400).json({ error: 'Nazwa kategorii jest wymagana' });
return res.status(400).json({ error: 'category_name_required' });
}
const category = {
id: uuid(),
@@ -41,7 +41,7 @@ router.put('/:id', (req, res) => {
.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' });
return res.status(404).json({ error: 'category_not_found' });
}
const { name, icon, color } = req.body || {};
db.prepare('UPDATE categories SET name = ?, icon = ?, color = ? WHERE id = ?').run(
@@ -58,7 +58,7 @@ router.delete('/:id', (req, res) => {
.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' });
return res.status(404).json({ error: 'category_not_found' });
}
res.status(204).end();
});

View File

@@ -5,6 +5,7 @@ const { requireAuth } = require('../middleware/auth');
const { requireHousehold, getMembers } = require('../utils/households');
const { sendMail } = require('../utils/mailer');
const { round2 } = require('../utils/balance');
const { getEmailTemplates } = require('../i18n');
const router = express.Router();
router.use(requireAuth, requireHousehold);
@@ -15,7 +16,7 @@ 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');
throw new Error('household_has_no_members');
}
const base = Math.floor((amount / memberIds.length) * 100) / 100;
const shares = Object.fromEntries(memberIds.map((id) => [id, base]));
@@ -28,21 +29,21 @@ function computeShares(splitType, amount, payerId, members, shares) {
if (splitType === 'exact' || splitType === 'full') {
if (!shares || typeof shares !== 'object') {
throw new Error('Pole "shares" jest wymagane dla wybranego typu podziału');
throw new Error('shares_required');
}
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');
throw new Error('shares_must_sum_to_amount');
}
for (const userId of Object.keys(shares)) {
if (!memberIds.includes(userId)) {
throw new Error('Udział przypisany do osoby spoza gospodarstwa domowego');
throw new Error('share_assigned_to_non_member');
}
}
return shares;
}
throw new Error('Nieznany typ podziału');
throw new Error('unknown_split_type');
}
function attachShares(expense) {
@@ -78,12 +79,12 @@ router.get('/', (req, res) => {
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' });
return res.status(400).json({ error: 'missing_expense_fields' });
}
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' });
return res.status(400).json({ error: 'payer_must_be_member' });
}
let computedShares;
@@ -131,17 +132,18 @@ router.post('/', (req, res) => {
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
`SELECT u.email, u.name, u.language 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 { subject, text } = getEmailTemplates(target.language).newExpense({
actorName: actor?.name || (target.language === 'en' ? 'Someone' : 'Ktoś'),
title: expense.title,
amount: expense.amount.toFixed(2),
});
sendMail({ to: target.email, subject, text });
}
const created = db.prepare('SELECT * FROM expenses WHERE id = ?').get(expense.id);
@@ -153,7 +155,7 @@ router.put('/:id', (req, res) => {
.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' });
return res.status(404).json({ error: 'expense_not_found' });
}
const { amount, title, categoryId, expenseDate, payerId, splitType, shares } = req.body || {};
@@ -163,7 +165,7 @@ router.put('/:id', (req, res) => {
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' });
return res.status(400).json({ error: 'payer_must_be_member' });
}
let computedShares;
@@ -206,7 +208,7 @@ router.delete('/:id', (req, res) => {
.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' });
return res.status(404).json({ error: 'expense_not_found' });
}
res.status(204).end();
});

View File

@@ -1,9 +1,10 @@
const express = require('express');
const crypto = require('crypto');
const { v4: uuid } = require('uuid');
const { db, DEFAULT_CATEGORIES } = require('../db/db');
const { db } = require('../db/db');
const { requireAuth } = require('../middleware/auth');
const { getHouseholdsForUser, getHouseholdById, isMember, getMembers } = require('../utils/households');
const { getDefaultCategories } = require('../i18n');
const router = express.Router();
router.use(requireAuth);
@@ -32,16 +33,18 @@ router.get('/', (req, res) => {
router.get('/:id', (req, res) => {
if (!isMember(req.params.id, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
const household = getHouseholdById(req.params.id);
if (!household) return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
if (!household) return res.status(404).json({ error: 'household_not_found' });
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 requester = db.prepare('SELECT language FROM users WHERE id = ?').get(req.userId);
const defaultName = requester?.language === 'en' ? 'Our household' : 'Nasze gospodarstwo';
const household = { id: uuid(), name: name || defaultName, currency: currency || 'PLN' };
const createHousehold = db.transaction(() => {
db.prepare('INSERT INTO households (id, name, currency) VALUES (?, ?, ?)').run(
@@ -50,7 +53,7 @@ router.post('/', (req, res) => {
household.currency
);
db.prepare('INSERT INTO household_members (household_id, user_id) VALUES (?, ?)').run(household.id, req.userId);
for (const cat of DEFAULT_CATEGORIES) {
for (const cat of getDefaultCategories(requester?.language)) {
db.prepare('INSERT INTO categories (id, household_id, name, icon, color) VALUES (?, ?, ?, ?, ?)').run(
uuid(),
household.id,
@@ -73,10 +76,10 @@ router.post('/', (req, res) => {
router.put('/:id', (req, res) => {
if (!isMember(req.params.id, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
const household = getHouseholdById(req.params.id);
if (!household) return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
if (!household) return res.status(404).json({ error: 'household_not_found' });
const { name, currency } = req.body || {};
db.prepare('UPDATE households SET name = ?, currency = ? WHERE id = ?').run(
@@ -89,7 +92,7 @@ router.put('/:id', (req, res) => {
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' });
return res.status(403).json({ error: 'not_a_household_member' });
}
db.prepare(`UPDATE invites SET used_at = datetime('now') WHERE household_id = ? AND used_at IS NULL`).run(
req.params.id
@@ -106,17 +109,17 @@ router.post('/:id/invite', (req, res) => {
router.post('/join', (req, res) => {
const { code } = req.body || {};
if (!code) {
return res.status(400).json({ error: 'Kod zaproszenia jest wymagany' });
return res.status(400).json({ error: 'invite_code_required' });
}
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ł' });
return res.status(404).json({ error: 'invite_code_invalid_or_expired' });
}
if (isMember(invite.household_id, req.userId)) {
return res.status(409).json({ error: 'Jesteś już członkiem tego gospodarstwa' });
return res.status(409).json({ error: 'already_a_member' });
}
db.prepare('INSERT INTO household_members (household_id, user_id) VALUES (?, ?)').run(
@@ -129,7 +132,7 @@ router.post('/join', (req, res) => {
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' });
return res.status(403).json({ error: 'not_a_household_member' });
}
db.prepare('DELETE FROM household_members WHERE household_id = ? AND user_id = ?').run(
req.params.id,
@@ -145,7 +148,7 @@ router.delete('/:id/members/:userId', (req, res) => {
router.delete('/:id', (req, res) => {
if (!isMember(req.params.id, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
db.prepare('DELETE FROM households WHERE id = ?').run(req.params.id);
res.json({ deleted: true });

View File

@@ -20,7 +20,7 @@ router.post('/', (req, res) => {
const current = computeSettlement(req.household.id, members);
if (current.settled) {
return res.status(409).json({ error: 'Jesteście już rozliczeni' });
return res.status(409).json({ error: 'already_settled' });
}
const insert = db.transaction(() => {
@@ -37,20 +37,20 @@ router.post('/', (req, res) => {
function validateManualSettlement(req, res, { fromUserId, toUserId, amount }) {
if (!fromUserId || !toUserId || !amount) {
res.status(400).json({ error: 'fromUserId, toUserId i amount są wymagane' });
res.status(400).json({ error: 'missing_settlement_fields' });
return false;
}
if (fromUserId === toUserId) {
res.status(400).json({ error: 'Płacący i odbiorca muszą być różnymi osobami' });
res.status(400).json({ error: 'payer_and_recipient_must_differ' });
return false;
}
if (Number(amount) <= 0) {
res.status(400).json({ error: 'Kwota musi być większa od zera' });
res.status(400).json({ error: 'amount_must_be_positive' });
return false;
}
const memberIds = getMembers(req.household.id).map((m) => m.id);
if (!memberIds.includes(fromUserId) || !memberIds.includes(toUserId)) {
res.status(400).json({ error: 'Obie osoby muszą być członkami gospodarstwa' });
res.status(400).json({ error: 'both_must_be_members' });
return false;
}
return true;
@@ -79,7 +79,7 @@ router.put('/:id', (req, res) => {
.prepare('SELECT * FROM settlements WHERE id = ? AND household_id = ?')
.get(req.params.id, req.household.id);
if (!existing) {
return res.status(404).json({ error: 'Rozliczenie nie znalezione' });
return res.status(404).json({ error: 'settlement_not_found' });
}
const fromUserId = req.body?.fromUserId ?? existing.from_user_id;
@@ -102,7 +102,7 @@ router.delete('/:id', (req, res) => {
.prepare('DELETE FROM settlements WHERE id = ? AND household_id = ?')
.run(req.params.id, req.household.id);
if (result.changes === 0) {
return res.status(404).json({ error: 'Rozliczenie nie znalezione' });
return res.status(404).json({ error: 'settlement_not_found' });
}
res.status(204).end();
});

View File

@@ -3,6 +3,7 @@ const { db } = require('../db/db');
const { requireAuth } = require('../middleware/auth');
const { requireHousehold, getMembers } = require('../utils/households');
const { computeSettlement } = require('../utils/balance');
const { getCsvExport } = require('../i18n');
const router = express.Router();
router.use(requireAuth, requireHousehold);
@@ -75,8 +76,10 @@ router.get('/export.csv', (req, res) => {
)
.all(req.household.id);
const requester = db.prepare('SELECT language FROM users WHERE id = ?').get(req.userId);
const { header, filename } = getCsvExport(requester?.language);
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(
@@ -85,7 +88,7 @@ router.get('/export.csv', (req, res) => {
}
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="wydatki.csv"');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(lines.join('\n'));
});

View File

@@ -35,14 +35,14 @@ function getMembers(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)' });
return res.status(400).json({ error: 'household_not_selected' });
}
if (!isMember(householdId, req.userId)) {
return res.status(403).json({ error: 'Nie jesteś członkiem tego gospodarstwa' });
return res.status(403).json({ error: 'not_a_household_member' });
}
const household = getHouseholdById(householdId);
if (!household) {
return res.status(404).json({ error: 'Gospodarstwo nie znalezione' });
return res.status(404).json({ error: 'household_not_found' });
}
req.household = household;
next();

View File

@@ -13,13 +13,13 @@ const transporter = SMTP_HOST
async function sendMail({ to, subject, text }) {
if (!transporter) {
console.log(`[mailer] SMTP nieskonfigurowane — pomijam wysyłkę do ${to}: "${subject}"`);
console.log(`[mailer] SMTP not configured — skipping send to ${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);
console.error('[mailer] Failed to send email:', err.message);
}
}