v1.0.3
add multi lang
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "ktoco-backend",
|
||||
"name": "whowhat-backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
84
backend/src/i18n.js
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user