From ae3b435b51b116fbf0d5bb0d3a71cc6394c455dc Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 11 Jul 2026 20:03:15 +0200 Subject: [PATCH] v1.0.3 add multi lang --- .env.example | 20 +- LICENSE | 21 ++ README.md | 192 ++++++++++-------- backend/package.json | 2 +- backend/src/db/db.js | 20 +- backend/src/db/schema.sql | 1 + backend/src/i18n.js | 84 ++++++++ backend/src/index.js | 2 +- backend/src/middleware/auth.js | 6 +- backend/src/routes/auth.js | 60 +++--- backend/src/routes/categories.js | 6 +- backend/src/routes/expenses.js | 32 +-- backend/src/routes/households.js | 29 +-- backend/src/routes/settlements.js | 14 +- backend/src/routes/stats.js | 7 +- backend/src/utils/households.js | 6 +- backend/src/utils/mailer.js | 4 +- docker-compose.yaml | 10 +- frontend/index.html | 4 +- frontend/package.json | 2 +- frontend/src/App.jsx | 4 +- frontend/src/api/client.js | 8 +- frontend/src/api/queries.js | 8 + frontend/src/auth/AuthContext.jsx | 17 +- frontend/src/components/BottomNav.jsx | 25 ++- frontend/src/components/CategoryPieChart.jsx | 6 +- .../src/components/ConfirmDialogProvider.jsx | 27 ++- frontend/src/components/ErrorBoundary.jsx | 18 +- frontend/src/components/ExpenseEditModal.jsx | 24 ++- frontend/src/components/ExpenseListItem.jsx | 4 +- frontend/src/components/InstallBanner.jsx | 14 +- frontend/src/components/LanguageSwitcher.jsx | 27 +++ frontend/src/components/MonthlyBarChart.jsx | 4 +- frontend/src/components/OfflineBanner.jsx | 6 +- frontend/src/components/PasswordField.jsx | 4 +- .../src/components/PayerComparisonChart.jsx | 4 +- frontend/src/components/SettingsButton.jsx | 4 +- .../src/components/SettlementEditModal.jsx | 22 +- .../src/components/SettlementListItem.jsx | 4 +- frontend/src/components/SplitSelector.jsx | 25 ++- frontend/src/i18n/I18nContext.jsx | 98 +++++++++ frontend/src/i18n/languages.js | 13 ++ frontend/src/i18n/locales/en/addExpense.json | 15 ++ frontend/src/i18n/locales/en/app.json | 3 + frontend/src/i18n/locales/en/bottomNav.json | 7 + frontend/src/i18n/locales/en/charts.json | 5 + frontend/src/i18n/locales/en/common.json | 22 ++ .../src/i18n/locales/en/confirmDialog.json | 5 + frontend/src/i18n/locales/en/dashboard.json | 8 + .../src/i18n/locales/en/errorBoundary.json | 5 + frontend/src/i18n/locales/en/errors.json | 40 ++++ .../src/i18n/locales/en/expenseEditModal.json | 9 + .../src/i18n/locales/en/expenseListItem.json | 3 + .../src/i18n/locales/en/forgotPassword.json | 9 + frontend/src/i18n/locales/en/history.json | 7 + .../src/i18n/locales/en/installBanner.json | 6 + frontend/src/i18n/locales/en/joinInvite.json | 5 + .../src/i18n/locales/en/languageSwitcher.json | 3 + frontend/src/i18n/locales/en/login.json | 11 + .../src/i18n/locales/en/offlineBanner.json | 4 + frontend/src/i18n/locales/en/onboarding.json | 14 ++ .../src/i18n/locales/en/passwordField.json | 4 + frontend/src/i18n/locales/en/register.json | 11 + .../src/i18n/locales/en/resetPassword.json | 12 ++ frontend/src/i18n/locales/en/settings.json | 81 ++++++++ .../src/i18n/locales/en/settingsButton.json | 3 + .../i18n/locales/en/settlementEditModal.json | 7 + .../i18n/locales/en/settlementListItem.json | 3 + frontend/src/i18n/locales/en/settlements.json | 17 ++ .../src/i18n/locales/en/splitSelector.json | 9 + frontend/src/i18n/locales/en/stats.json | 6 + frontend/src/i18n/locales/pl/addExpense.json | 15 ++ frontend/src/i18n/locales/pl/app.json | 3 + frontend/src/i18n/locales/pl/bottomNav.json | 7 + frontend/src/i18n/locales/pl/charts.json | 5 + frontend/src/i18n/locales/pl/common.json | 22 ++ .../src/i18n/locales/pl/confirmDialog.json | 5 + frontend/src/i18n/locales/pl/dashboard.json | 8 + .../src/i18n/locales/pl/errorBoundary.json | 5 + frontend/src/i18n/locales/pl/errors.json | 40 ++++ .../src/i18n/locales/pl/expenseEditModal.json | 9 + .../src/i18n/locales/pl/expenseListItem.json | 3 + .../src/i18n/locales/pl/forgotPassword.json | 9 + frontend/src/i18n/locales/pl/history.json | 7 + .../src/i18n/locales/pl/installBanner.json | 6 + frontend/src/i18n/locales/pl/joinInvite.json | 5 + .../src/i18n/locales/pl/languageSwitcher.json | 3 + frontend/src/i18n/locales/pl/login.json | 11 + .../src/i18n/locales/pl/offlineBanner.json | 4 + frontend/src/i18n/locales/pl/onboarding.json | 14 ++ .../src/i18n/locales/pl/passwordField.json | 4 + frontend/src/i18n/locales/pl/register.json | 11 + .../src/i18n/locales/pl/resetPassword.json | 12 ++ frontend/src/i18n/locales/pl/settings.json | 81 ++++++++ .../src/i18n/locales/pl/settingsButton.json | 3 + .../i18n/locales/pl/settlementEditModal.json | 7 + .../i18n/locales/pl/settlementListItem.json | 3 + frontend/src/i18n/locales/pl/settlements.json | 17 ++ .../src/i18n/locales/pl/splitSelector.json | 9 + frontend/src/i18n/locales/pl/stats.json | 6 + frontend/src/main.jsx | 29 +-- frontend/src/offline/db.js | 2 +- frontend/src/pages/AddExpense.jsx | 28 +-- frontend/src/pages/Dashboard.jsx | 20 +- frontend/src/pages/ForgotPassword.jsx | 16 +- frontend/src/pages/History.jsx | 14 +- frontend/src/pages/JoinInvite.jsx | 10 +- frontend/src/pages/Login.jsx | 28 ++- frontend/src/pages/Onboarding.jsx | 33 +-- frontend/src/pages/Register.jsx | 30 +-- frontend/src/pages/ResetPassword.jsx | 30 ++- frontend/src/pages/Settings.jsx | 171 ++++++++-------- frontend/src/pages/Settlements.jsx | 34 ++-- frontend/src/pages/Stats.jsx | 18 +- frontend/src/theme/ThemeContext.jsx | 2 +- frontend/vite.config.js | 6 +- 116 files changed, 1543 insertions(+), 484 deletions(-) create mode 100644 LICENSE create mode 100644 backend/src/i18n.js create mode 100644 frontend/src/components/LanguageSwitcher.jsx create mode 100644 frontend/src/i18n/I18nContext.jsx create mode 100644 frontend/src/i18n/languages.js create mode 100644 frontend/src/i18n/locales/en/addExpense.json create mode 100644 frontend/src/i18n/locales/en/app.json create mode 100644 frontend/src/i18n/locales/en/bottomNav.json create mode 100644 frontend/src/i18n/locales/en/charts.json create mode 100644 frontend/src/i18n/locales/en/common.json create mode 100644 frontend/src/i18n/locales/en/confirmDialog.json create mode 100644 frontend/src/i18n/locales/en/dashboard.json create mode 100644 frontend/src/i18n/locales/en/errorBoundary.json create mode 100644 frontend/src/i18n/locales/en/errors.json create mode 100644 frontend/src/i18n/locales/en/expenseEditModal.json create mode 100644 frontend/src/i18n/locales/en/expenseListItem.json create mode 100644 frontend/src/i18n/locales/en/forgotPassword.json create mode 100644 frontend/src/i18n/locales/en/history.json create mode 100644 frontend/src/i18n/locales/en/installBanner.json create mode 100644 frontend/src/i18n/locales/en/joinInvite.json create mode 100644 frontend/src/i18n/locales/en/languageSwitcher.json create mode 100644 frontend/src/i18n/locales/en/login.json create mode 100644 frontend/src/i18n/locales/en/offlineBanner.json create mode 100644 frontend/src/i18n/locales/en/onboarding.json create mode 100644 frontend/src/i18n/locales/en/passwordField.json create mode 100644 frontend/src/i18n/locales/en/register.json create mode 100644 frontend/src/i18n/locales/en/resetPassword.json create mode 100644 frontend/src/i18n/locales/en/settings.json create mode 100644 frontend/src/i18n/locales/en/settingsButton.json create mode 100644 frontend/src/i18n/locales/en/settlementEditModal.json create mode 100644 frontend/src/i18n/locales/en/settlementListItem.json create mode 100644 frontend/src/i18n/locales/en/settlements.json create mode 100644 frontend/src/i18n/locales/en/splitSelector.json create mode 100644 frontend/src/i18n/locales/en/stats.json create mode 100644 frontend/src/i18n/locales/pl/addExpense.json create mode 100644 frontend/src/i18n/locales/pl/app.json create mode 100644 frontend/src/i18n/locales/pl/bottomNav.json create mode 100644 frontend/src/i18n/locales/pl/charts.json create mode 100644 frontend/src/i18n/locales/pl/common.json create mode 100644 frontend/src/i18n/locales/pl/confirmDialog.json create mode 100644 frontend/src/i18n/locales/pl/dashboard.json create mode 100644 frontend/src/i18n/locales/pl/errorBoundary.json create mode 100644 frontend/src/i18n/locales/pl/errors.json create mode 100644 frontend/src/i18n/locales/pl/expenseEditModal.json create mode 100644 frontend/src/i18n/locales/pl/expenseListItem.json create mode 100644 frontend/src/i18n/locales/pl/forgotPassword.json create mode 100644 frontend/src/i18n/locales/pl/history.json create mode 100644 frontend/src/i18n/locales/pl/installBanner.json create mode 100644 frontend/src/i18n/locales/pl/joinInvite.json create mode 100644 frontend/src/i18n/locales/pl/languageSwitcher.json create mode 100644 frontend/src/i18n/locales/pl/login.json create mode 100644 frontend/src/i18n/locales/pl/offlineBanner.json create mode 100644 frontend/src/i18n/locales/pl/onboarding.json create mode 100644 frontend/src/i18n/locales/pl/passwordField.json create mode 100644 frontend/src/i18n/locales/pl/register.json create mode 100644 frontend/src/i18n/locales/pl/resetPassword.json create mode 100644 frontend/src/i18n/locales/pl/settings.json create mode 100644 frontend/src/i18n/locales/pl/settingsButton.json create mode 100644 frontend/src/i18n/locales/pl/settlementEditModal.json create mode 100644 frontend/src/i18n/locales/pl/settlementListItem.json create mode 100644 frontend/src/i18n/locales/pl/settlements.json create mode 100644 frontend/src/i18n/locales/pl/splitSelector.json create mode 100644 frontend/src/i18n/locales/pl/stats.json diff --git a/.env.example b/.env.example index 87a2a64..509bc4c 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,20 @@ -# Skopiuj ten plik do .env i uzupełnij wartości: cp .env.example .env +# Copy this file to .env and fill in the values: cp .env.example .env -# --- Bezpieczeństwo --- -# Wygeneruj losowy sekret np. poleceniem: openssl rand -hex 32 +# --- Security --- +# Generate a random secret with, e.g.: openssl rand -hex 32 JWT_SECRET=change-me-to-a-random-secret -# --- Adres aplikacji --- -FRONTEND_URL=https://twoja-domena.pl +# --- App URL --- +FRONTEND_URL=https://example.com -# --- Domena (routing Traefik) --- -DOMAIN=twoja-domena.pl +# --- Domain (Traefik routing) --- +DOMAIN=example.com -# --- Sieć zewnętrzna Traefik --- +# --- External Traefik network --- TRAEFIK_NETWORK=traefik_public -# --- SMTP (powiadomienia mailowe, reset hasła) --- -# Pozostaw SMTP_HOST puste, aby wyłączyć wysyłkę e-maili. +# --- SMTP (email notifications, password reset) --- +# Leave SMTP_HOST empty to disable sending emails. SMTP_HOST= SMTP_PORT=587 SMTP_SECURE=false diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a09acf6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Kacper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 0c08be6..313a2eb 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,165 @@ -# KtoCo — wspólne wydatki dla grup, par i współlokatorów +# WhoWhat — shared expenses for groups, couples and roommates -Aplikacja webowa (PWA) do dzielenia się wydatkami w gospodarstwie domowym — kto ile wydał, kto komu jest winien, z historią, statystykami i rozliczeniami. Obsługuje dowolną liczbę osób w gospodarstwie i dowolną liczbę gospodarstw na użytkownika. Mobile-first, instalowalna na telefonie, działa częściowo offline. +A web app (PWA) for splitting expenses within a household — who paid what, who owes whom, with history, stats and settlements. Supports any number of people per household and any number of households per user. Mobile-first, installable on your phone, works partially offline. Available in Polish and English, with more languages easy to add. -## Funkcje +## Features -- **Dashboard** — kafelki „kto komu ile jest winien" (automatycznie uproszczone do minimalnej liczby przelewów dla całej grupy) z przyciskiem „Rozlicz się", wykres kołowy wydatków wg kategorii, podsumowanie miesiąca. -- **Dodawanie wydatku** — duże pole kwoty, kategorie z ikonami, wybór płacącego, trzy tryby podziału: po równo (między wszystkich członków), dokładny podział, całość na jedną osobę. -- **Historia** — lista wydatków z filtrami (miesiąc/kategoria/płacący), edycja i usuwanie. -- **Statystyki** — wydatki miesiąc do miesiąca, porównanie konsumpcji członków gospodarstwa, ranking kategorii. -- **Wiele gospodarstw** — użytkownik może należeć do wielu gospodarstw jednocześnie i przełączać się między nimi (Ustawienia → „Twoje gospodarstwa"); każde gospodarstwo może mieć dowolną liczbę członków, dołączanych tym samym kodem zaproszenia. -- **Ustawienia** — przełącznik i zarządzanie gospodarstwami (zmiana nazwy/waluty, usuwanie członka, usunięcie całego gospodarstwa, zaproszenia), własne kategorie (z edycją nazwy/ikony), motyw jasny/ciemny/systemowy, powiadomienia mailowe, eksport CSV. -- **Konto** — rejestracja e-mail/hasło, logowanie, zmiana nazwy, zmiana hasła, przypomnienie/reset hasła mailem, usunięcie konta. -- **PWA / offline** — instalowalna na ekranie głównym telefonu, cache widoków, wydatki dodane offline trafiają do kolejki i synchronizują się automatycznie po powrocie sieci. +- **Dashboard** — "who owes whom" tiles (automatically simplified to the minimum number of transfers for the whole group) with a "Settle up" button, a pie chart of expenses by category, and a month summary. +- **Add expense** — large amount field, categories with icons, payer selection, three split modes: equally (among all members), exact amounts, or the full amount to one person. +- **History** — expense list with filters (month/category/payer), edit and delete. +- **Stats** — month-over-month expenses, comparison of household members' spending, category ranking. +- **Multiple households** — a user can belong to several households at once and switch between them (Settings → "Your households"); each household can have any number of members, who join with the same invite code. +- **Settings** — switch and manage households (rename/change currency, remove a member, delete a household, invites), custom categories (rename/re-icon), light/dark/system theme, **language switcher (Polish/English)**, email notifications, CSV export. +- **Account** — email/password registration, login, rename, change password, password reminder/reset by email, account deletion. +- **PWA / offline** — installable on your phone's home screen, cached views, expenses added offline are queued and sync automatically once the connection is back. -## Stack technologiczny +## Tech stack -| Warstwa | Technologia | +| Layer | Technology | |---|---| | Frontend | React (Vite), react-router-dom, TanStack Query, Recharts, vite-plugin-pwa, Material Symbols (Google Fonts) | | Backend | Node.js + Express, better-sqlite3, JWT (jsonwebtoken), bcryptjs, nodemailer | -| Baza danych | SQLite (plik na bind mouncie `./sqlite`) | -| Infrastruktura | Docker Compose, nginx (serwuje frontend + proxy `/api`), opcjonalnie Traefik (TLS + routing domenowy) | +| Database | SQLite (a file on a bind mount at `./sqlite`) | +| Infrastructure | Docker Compose, nginx (serves the frontend + proxies `/api`), optionally Traefik (TLS + domain routing) | -## Struktura projektu +## Internationalization (i18n) + +The UI ships in Polish and English out of the box, switchable at any time from Settings → Language (persisted to `localStorage`, and to the user's account once logged in, so transactional emails and CSV exports match their preference too). Backend API errors are returned as stable machine-readable codes (e.g. `invalid_credentials`) and translated client-side — the server never hardcodes user-facing language. + +Frontend translations live in `frontend/src/i18n/locales//.json`, one JSON file per component/page, auto-discovered at build time (no registration step). **To add a new language:** copy the `locales/en/` directory to `locales//`, translate every value, and add `{ code, label }` to `frontend/src/i18n/languages.js`. Backend-rendered content (emails, default category names, CSV headers) lives in `backend/src/i18n.js` — add the new language code there too if you want those translated as well. + +## Project structure ``` -ktoco/ -├── docker-compose.yaml # jedyny plik potrzebny do uruchomienia całości -├── .env # konfiguracja (sekrety, domena, SMTP) — NIE commitować -├── .env.example # szablon konfiguracji do skopiowania -├── sqlite/ # bind mount — tu leży plik app.db (trwałość danych) +whowhat/ +├── docker-compose.yaml # the only file needed to run everything +├── .env # configuration (secrets, domain, SMTP) — do NOT commit +├── .env.example # configuration template to copy +├── sqlite/ # bind mount — app.db lives here (data persistence) ├── backend/ │ ├── Dockerfile │ └── src/ -│ ├── index.js # Express app, montowanie routerów -│ ├── db/ # schema.sql + połączenie better-sqlite3 -│ ├── middleware/auth.js # weryfikacja JWT +│ ├── index.js # Express app, router mounting +│ ├── i18n.js # server-rendered translations (emails, CSV, default categories) +│ ├── db/ # schema.sql + better-sqlite3 connection +│ ├── middleware/auth.js # JWT verification │ ├── routes/ # auth, households, categories, expenses, settlements, stats -│ └── utils/ # obliczanie salda, mailer (nodemailer), pomocnicze household +│ └── utils/ # balance calculation, mailer (nodemailer), household helpers └── frontend/ ├── Dockerfile # multi-stage: build (node) -> serve (nginx) - ├── nginx.conf # proxy /api -> backend:3000 - ├── vite.config.js # konfiguracja PWA (manifest, service worker) + ├── nginx.conf # proxies /api -> backend:3000 + ├── vite.config.js # PWA config (manifest, service worker) └── src/ + ├── i18n/ # I18nContext, per-namespace locale JSON files ├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Login, Register, ... - ├── components/ # BottomNav, wykresy, formularze, Icon, Switch, ... - ├── api/ # klient fetch + hooki React Query - ├── auth/ # kontekst autoryzacji (JWT w localStorage) - ├── household/ # kontekst aktywnego gospodarstwa (lista + przełączanie) - ├── theme/ # kontekst motywu jasny/ciemny/systemowy - └── offline/ # kolejka IndexedDB + synchronizacja po powrocie sieci + ├── components/ # BottomNav, charts, forms, Icon, Switch, LanguageSwitcher, ... + ├── api/ # fetch client + React Query hooks + ├── auth/ # auth context (JWT in localStorage) + ├── household/ # active household context (list + switching) + ├── theme/ # light/dark/system theme context + └── offline/ # IndexedDB queue + sync on reconnect ``` -## Uruchomienie +## Running it -Wymagany jest tylko Docker (z pluginem Compose). +Docker (with the Compose plugin) is the only requirement. ```bash cp .env.example .env -# uzupełnij .env (patrz sekcja niżej) — nie trzeba edytować docker-compose.yaml +# fill in .env (see below) — no need to edit docker-compose.yaml sudo docker compose up -d --build ``` -Aplikacja będzie dostępna pod `http://localhost:8856` (oraz pod domeną z Traefika, jeśli skonfigurowana — patrz niżej). +The app will be available at `http://localhost:8856` (and at your Traefik domain, if configured — see below). -## Konfiguracja (`.env`) +## Configuration (`.env`) -Cała konfiguracja wdrożeniowa znajduje się w `.env` — `docker-compose.yaml` nie wymaga edycji. +All deployment configuration lives in `.env` — `docker-compose.yaml` doesn't need editing. -| Zmienna | Opis | Domyślnie | +| Variable | Description | Default | |---|---|---| -| `JWT_SECRET` | Sekret do podpisywania tokenów logowania. Wygeneruj: `openssl rand -hex 32` | — (wymagany) | -| `FRONTEND_URL` | Publiczny adres aplikacji, używany w linkach w mailach (np. reset hasła) | `https://ktoco.kzbikowski.pl` | -| `DOMAIN` | Domena, pod którą Traefik wystawia aplikację | `ktoco.kzbikowski.pl` | -| `TRAEFIK_NETWORK` | Nazwa istniejącej zewnętrznej sieci Docker, do której podłączony jest Traefik | `traefik_public` | -| `SMTP_HOST` | Adres serwera SMTP. Puste = wysyłka maili wyłączona (tylko log w konsoli) | — (opcjonalne) | -| `SMTP_PORT` | Port SMTP | `587` | -| `SMTP_SECURE` | `true` dla połączenia SSL/TLS od razu (port 465), inaczej `false` (STARTTLS) | `false` | -| `SMTP_USER` / `SMTP_PASS` | Dane logowania do SMTP | — | -| `SMTP_FROM` | Adres nadawcy w wysyłanych mailach | `SMTP_USER` | +| `JWT_SECRET` | Secret used to sign login tokens. Generate with: `openssl rand -hex 32` | — (required) | +| `FRONTEND_URL` | Public URL of the app, used in email links (e.g. password reset) | `https://example.com` | +| `DOMAIN` | Domain Traefik should expose the app on | `example.com` | +| `TRAEFIK_NETWORK` | Name of the existing external Docker network Traefik is attached to | `traefik_public` | +| `SMTP_HOST` | SMTP server address. Empty = email sending disabled (console log only) | — (optional) | +| `SMTP_PORT` | SMTP port | `587` | +| `SMTP_SECURE` | `true` for immediate SSL/TLS (port 465), otherwise `false` (STARTTLS) | `false` | +| `SMTP_USER` / `SMTP_PASS` | SMTP login credentials | — | +| `SMTP_FROM` | Sender address on outgoing emails | `SMTP_USER` | -Bez skonfigurowanego SMTP aplikacja działa normalnie — funkcje „reset hasła" i „powiadomienia mailowe" po prostu nie wysyłają realnych maili (backend loguje w konsoli, że wysyłkę pominięto). +Without SMTP configured the app works normally — "password reset" and "email notifications" simply don't send real emails (the backend logs to the console that sending was skipped). -## Dane / trwałość +## Data / persistence -Baza SQLite leży w `./sqlite/app.db` na hoście (bind mount, nie nazwany wolumen Dockera) — łatwo ją skopiować, zbackupować albo podejrzeć narzędziem `sqlite3` bez wchodzenia do kontenera. +The SQLite database lives at `./sqlite/app.db` on the host (a bind mount, not a named Docker volume) — easy to copy, back up, or inspect with the `sqlite3` CLI without entering the container. -## Wdrożenie za Traefikiem +## Deploying behind Traefik -Serwis `frontend` jest podłączony do zewnętrznej sieci `traefik_public` (nazwa konfigurowalna przez `TRAEFIK_NETWORK`) i ma etykiety Traefika (routing po domenie z `.env`, TLS przez `tls-resolver`). Warunek: sieć `traefik_public` musi już istnieć na hoście (tworzy ją zwykle stack samego Traefika): +The `frontend` service is attached to the external `traefik_public` network (name configurable via `TRAEFIK_NETWORK`) and carries Traefik labels (domain routing from `.env`, TLS via `tls-resolver`). Prerequisite: the `traefik_public` network must already exist on the host (normally created by the Traefik stack itself): ```bash -docker network create traefik_public # tylko jeśli jeszcze nie istnieje +docker network create traefik_public # only if it doesn't exist yet ``` -Port `8856` frontend jest dodatkowo opublikowany bezpośrednio na hosta — przydatne przy testach lokalnych równolegle z dostępem przez Traefik. +The frontend's port `8856` is also published directly on the host — useful for local testing alongside access through Traefik. -## Model danych (SQLite) +## Data model (SQLite) -- `users` — konta (e-mail, hash hasła, preferencja powiadomień mailowych) -- `households` — gospodarstwa domowe (nazwa, waluta) -- `household_members` — członkowie gospodarstwa (dowolna liczba osób; użytkownik może być w wielu gospodarstwach naraz) -- `invites` — kody zaproszeń do gospodarstwa (ważne 7 dni, wielokrotnego użytku — nie wygasają po jednym dołączeniu) -- `password_resets` — jednorazowe tokeny resetu hasła (ważne 1h) -- `categories` — kategorie wydatków (nazwa, ikona Material Symbols, kolor) -- `expenses` — wydatki (kwota, płacący, kategoria, data, typ podziału) -- `expense_shares` — finalny podział wydatku między członków gospodarstwa (niezależnie od typu podziału zawsze sumuje się do kwoty wydatku) -- `settlements` — historia rozliczeń („Rozlicz się") +- `users` — accounts (email, password hash, email notification preference, UI language) +- `households` — households (name, currency) +- `household_members` — household membership (any number of people; a user can be in several households at once) +- `invites` — household invite codes (valid 7 days, reusable — they don't expire after a single join) +- `password_resets` — one-time password reset tokens (valid 1h) +- `categories` — expense categories (name, Material Symbols icon, color) +- `expenses` — expenses (amount, payer, category, date, split type) +- `expense_shares` — the final split of an expense across household members (always sums to the expense amount regardless of split type) +- `settlements` — settlement history ("Settle up") -Saldo per osoba liczone jest jako: `(suma zapłacona przez osobę) − (suma jej udziałów w wydatkach) − (netto rozliczeń)`. Do prezentacji „kto komu ile jest winien" salda są upraszczane zachłannym algorytmem (`backend/src/utils/balance.js: simplifyDebts`), który dla N osób generuje minimalną liczbę przelewów rozliczających wszystkich (zamiast osobnego długu między każdą parą). +Per-person balance is computed as: `(amount they paid) − (sum of their expense shares) − (net settlements)`. For the "who owes whom" display, balances are simplified with a greedy algorithm (`backend/src/utils/balance.js: simplifyDebts`) that produces the minimum number of transfers to settle everyone (instead of a separate debt between every pair). -## Wiele gospodarstw — jak to działa +New rows in `users` get a `language` column (`'pl'` or `'en'`, default `'pl'`); on an existing database this is added automatically on startup via `ALTER TABLE ... ADD COLUMN` — no manual migration, and no existing rows are touched or deleted. -Użytkownik może należeć do wielu gospodarstw. Ponieważ każdy request do zasobów powiązanych z gospodarstwem (wydatki, kategorie, saldo, statystyki, rozliczenia) musi wiedzieć, którego gospodarstwa dotyczy, frontend wysyła nagłówek `X-Household-Id: ` przy każdym takim żądaniu (ustawiany automatycznie przez `frontend/src/household/HouseholdContext.jsx` po przełączeniu gospodarstwa w Ustawieniach). Backend weryfikuje w `requireHousehold` middleware, że zalogowany użytkownik faktycznie jest członkiem podanego gospodarstwa. +## Multiple households — how it works -Usunięcie ostatniego członka z gospodarstwa automatycznie kasuje samo gospodarstwo (wraz z całą historią wydatków — kasowanie kaskadowe przez klucze obce). Usunięcie konta użytkownika, który ma współdzieloną historię finansową z innymi (wydatki/udziały/rozliczenia), nie usuwa go fizycznie z bazy (zepsułoby to historię widoczną dla reszty gospodarstwa) — konto jest wtedy anonimizowane (nazwa → „Usunięte konto", e-mail zastąpiony unikalnym nieistniejącym adresem, hasło unieważnione). Świeże konto bez żadnej historii jest usuwane w całości. +A user can belong to multiple households. Since every request for household-scoped resources (expenses, categories, balance, stats, settlements) needs to know which household it's about, the frontend sends an `X-Household-Id: ` header on every such request (set automatically by `frontend/src/household/HouseholdContext.jsx` whenever the active household is switched in Settings). The backend's `requireHousehold` middleware verifies the logged-in user is actually a member of the given household. -## API (skrót) +Removing the last member from a household automatically deletes the household itself (along with its entire expense history — cascading deletes via foreign keys). Deleting a user account that has shared financial history with others (expenses/shares/settlements) doesn't physically remove it from the database (that would break the history visible to the rest of the household) — instead the account is anonymized (name → "Deleted account", email replaced with a unique non-existent address, password invalidated). A fresh account with no history is deleted outright. -Wszystkie endpointy poza `/auth/register`, `/auth/login`, `/auth/forgot-password`, `/auth/reset-password` i `/health` wymagają nagłówka `Authorization: Bearer `. Endpointy gospodarstwa/kategorii/wydatków/rozliczeń/statystyk dodatkowo wymagają `X-Household-Id: `. +## API (overview) -| Grupa | Endpointy | +Every endpoint except `/auth/register`, `/auth/login`, `/auth/forgot-password`, `/auth/reset-password` and `/health` requires an `Authorization: Bearer ` header. Household/category/expense/settlement/stats endpoints additionally require `X-Household-Id: `. + +Error responses are `{ "error": "" }`, where `` is a stable snake_case identifier (e.g. `invalid_credentials`, `household_not_found`) meant to be translated client-side — see [`frontend/src/i18n/locales/en/errors.json`](frontend/src/i18n/locales/en/errors.json) for the full list. + +| Group | Endpoints | |---|---| -| Auth | `POST /auth/register`, `/login`, `/change-password`, `/forgot-password`, `/reset-password`, `GET /auth/me`, `PUT /auth/me`, `PUT /auth/me/notifications`, `DELETE /auth/me` | -| Gospodarstwa | `GET /households` (lista Twoich), `GET/PUT/DELETE /households/:id`, `POST /households`, `POST /households/:id/invite`, `POST /households/join`, `DELETE /households/:id/members/:userId` | -| Kategorie | `GET/POST/PUT/DELETE /categories[/:id]` | -| Wydatki | `GET/POST/PUT/DELETE /expenses[/:id]` (filtry: `month`, `categoryId`, `payerId`) | -| Rozliczenia | `GET/POST /settlements` (POST rozlicza od razu wszystkie uproszczone przelewy) | -| Statystyki | `GET /stats/balance`, `/summary`, `/monthly`, `/export.csv` | +| Auth | `POST /auth/register`, `/login`, `/change-password`, `/forgot-password`, `/reset-password`, `GET /auth/me`, `PUT /auth/me`, `PUT /auth/me/notifications`, `PUT /auth/me/language`, `DELETE /auth/me` | +| Households | `GET /households` (yours), `GET/PUT/DELETE /households/:id`, `POST /households`, `POST /households/:id/invite`, `POST /households/join`, `DELETE /households/:id/members/:userId` | +| Categories | `GET/POST/PUT/DELETE /categories[/:id]` | +| Expenses | `GET/POST/PUT/DELETE /expenses[/:id]` (filters: `month`, `categoryId`, `payerId`) | +| Settlements | `GET/POST /settlements` (POST immediately settles all simplified transfers) | +| Stats | `GET /stats/balance`, `/summary`, `/monthly`, `/export.csv` | -## Tryb offline (PWA) +## Offline mode (PWA) -Service worker (Workbox, przez `vite-plugin-pwa`) cache'uje powłokę aplikacji i ostatnio pobrane dane GET z API (strategia `NetworkFirst`), więc appka otwiera się i pokazuje dane nawet bez sieci. Nowy wydatek dodany offline trafia do kolejki w IndexedDB (`frontend/src/offline/`) i zostaje automatycznie wysłany po wykryciu powrotu połączenia (`online` event) — widoczny jest wtedy baner z liczbą oczekujących wpisów. +A service worker (Workbox, via `vite-plugin-pwa`) caches the app shell and recently fetched GET data from the API (`NetworkFirst` strategy), so the app opens and shows data even without a connection. A new expense added offline is queued in IndexedDB (`frontend/src/offline/`) and sent automatically once the connection comes back (`online` event) — a banner then shows the number of pending entries. -## Znane ograniczenia +## Known limitations -- Kopiowanie kodu zaproszenia przez `navigator.clipboard` wymaga bezpiecznego kontekstu (HTTPS lub `localhost`) — na zwykłym HTTP w sieci lokalnej przeglądarka może to zablokować; dlatego kod jest zawsze dostępny też jako zaznaczalne pole tekstowe (ręczne kopiowanie zawsze działa). -- `schema.sql` używa `CREATE TABLE IF NOT EXISTS` — dodanie nowej kolumny do istniejącej tabeli w już działającej bazie wymaga ręcznej migracji (`ALTER TABLE`); przy starcie na czystej bazie schemat tworzy się poprawnie od razu. -- Kod zaproszenia do gospodarstwa nie wygasa po pierwszym użyciu (celowo — pozwala zaprosić dowolną liczbę osób tym samym kodem), tylko po czasie (7 dni) lub ręcznym wygenerowaniu nowego w Ustawieniach. +- Copying the invite code via `navigator.clipboard` requires a secure context (HTTPS or `localhost`) — over plain HTTP on a local network the browser may block it; the code is therefore always also available as a selectable text field (manual copy always works). +- `schema.sql` uses `CREATE TABLE IF NOT EXISTS` — adding a new column to an existing table on an already-running database requires either a manual migration (`ALTER TABLE`) or startup logic like the one already in place for `users.language` (see `backend/src/db/db.js`); a fresh database gets the current schema immediately. +- A household invite code doesn't expire after first use (intentionally — it lets you invite any number of people with the same code), only after time (7 days) or a manual regeneration in Settings. -## Rozwój lokalny (bez Dockera) +## Local development (without Docker) -Wymaga Node.js 20+. +Requires Node.js 20+. ```bash cd backend && npm install && JWT_SECRET=dev DATABASE_PATH=./data/app.db npm start -cd frontend && npm install && npm run dev # serwer dev na :5173, proxy /api -> :3000 +cd frontend && npm install && npm run dev # dev server on :5173, proxies /api -> :3000 ``` + +## License + +[MIT](LICENSE) diff --git a/backend/package.json b/backend/package.json index cdf12e7..5479b7f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,5 +1,5 @@ { - "name": "ktoco-backend", + "name": "whowhat-backend", "version": "1.0.0", "private": true, "type": "commonjs", diff --git a/backend/src/db/db.js b/backend/src/db/db.js index 59a706c..fa12420 100644 --- a/backend/src/db/db.js +++ b/backend/src/db/db.js @@ -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 }; diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 69e96aa..b062104 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -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')) ); diff --git a/backend/src/i18n.js b/backend/src/i18n.js new file mode 100644 index 0000000..d5f3dad --- /dev/null +++ b/backend/src/i18n.js @@ -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, +}; diff --git a/backend/src/index.js b/backend/src/index.js index 9738af3..651c907 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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; diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js index 08b48e5..cab6fd4 100644 --- a/backend/src/middleware/auth.js +++ b/backend/src/middleware/auth.js @@ -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(); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 9a5ee87..8246bf6 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -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); diff --git a/backend/src/routes/categories.js b/backend/src/routes/categories.js index a15e515..6150c3a 100644 --- a/backend/src/routes/categories.js +++ b/backend/src/routes/categories.js @@ -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(); }); diff --git a/backend/src/routes/expenses.js b/backend/src/routes/expenses.js index 312cd13..c95f573 100644 --- a/backend/src/routes/expenses.js +++ b/backend/src/routes/expenses.js @@ -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(); }); diff --git a/backend/src/routes/households.js b/backend/src/routes/households.js index 07e8f86..8e079a5 100644 --- a/backend/src/routes/households.js +++ b/backend/src/routes/households.js @@ -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 }); diff --git a/backend/src/routes/settlements.js b/backend/src/routes/settlements.js index 89a910e..7379c83 100644 --- a/backend/src/routes/settlements.js +++ b/backend/src/routes/settlements.js @@ -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(); }); diff --git a/backend/src/routes/stats.js b/backend/src/routes/stats.js index 3aecde7..1b6585d 100644 --- a/backend/src/routes/stats.js +++ b/backend/src/routes/stats.js @@ -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')); }); diff --git a/backend/src/utils/households.js b/backend/src/utils/households.js index d53265e..0b009a1 100644 --- a/backend/src/utils/households.js +++ b/backend/src/utils/households.js @@ -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(); diff --git a/backend/src/utils/mailer.js b/backend/src/utils/mailer.js index f784624..f61c92b 100644 --- a/backend/src/utils/mailer.js +++ b/backend/src/utils/mailer.js @@ -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); } } diff --git a/docker-compose.yaml b/docker-compose.yaml index f53ce57..e27ece0 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,7 +5,7 @@ services: JWT_SECRET: ${JWT_SECRET:-please-change-this-secret-in-.env} DATABASE_PATH: /app/data/app.db PORT: 3000 - FRONTEND_URL: ${FRONTEND_URL:-https://ktoco.kzbikowski.pl} + FRONTEND_URL: ${FRONTEND_URL:-https://example.com} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURE: ${SMTP_SECURE:-false} @@ -30,10 +30,10 @@ services: labels: - traefik.enable=true - traefik.docker.network=traefik_public - - traefik.http.routers.ktoco.rule=Host(`${DOMAIN:-ktoco.kzbikowski.pl}`) - - traefik.http.routers.ktoco.entrypoints=websecure - - traefik.http.routers.ktoco.tls.certresolver=tls-resolver - - traefik.http.routers.ktoco.tls=true + - traefik.http.routers.whowhat.rule=Host(`${DOMAIN:-example.com}`) + - traefik.http.routers.whowhat.entrypoints=websecure + - traefik.http.routers.whowhat.tls.certresolver=tls-resolver + - traefik.http.routers.whowhat.tls=true restart: unless-stopped networks: diff --git a/frontend/index.html b/frontend/index.html index 52a1fdc..2570b11 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - KtoCo - Wydatki wspólne + WhoWhat - Shared Expenses @@ -14,7 +14,7 @@ />