Skip to content

Repository files navigation

Travel Journal

A personal travel journal: record trips, track what you spent, keep the photos and the story together, and watch a map fill in as you go.

Everything is private to your account.


Stack

Layer Choice Why
Backend Python 3.12 · FastAPI Async, typed, generates its own OpenAPI docs
Database PostgreSQL 17 · SQLAlchemy 2.0 (async) · Alembic Relational integrity for money and ownership; versioned schema
Auth Argon2id + JWT access tokens + rotating refresh cookies Memory-hard hashing; short-lived tokens; stolen-token detection
Frontend React 19 · TypeScript · Vite 6 Type safety across the API boundary; fast builds
Data layer TanStack Query 5 Caching, background refetch, no hand-rolled loading state
Styling Tailwind CSS 3 The original theme, expressed as semantic tokens
Maps Leaflet · OpenStreetMap No API key, no billing account
Tests pytest (real Postgres) · Vitest + Testing Library Tests exercise the actual dialect and the real DOM

The visual theme is unchanged from the original app — the same deep-green navigation, teal accents, Outfit typeface and category colours. They are now declared once as tokens (brand, forest, money, place, transit, hurdle) in frontend/tailwind.config.js instead of being repeated per component.


Running it

Docker is the only prerequisite; Node and Python are not needed on the host.

docker compose up --build
Service URL
App http://localhost:5173
API http://localhost:8000
API docs (Swagger) http://localhost:8000/docs
Database localhost:5432travel / travel

Migrations run automatically on backend start, so the first boot leaves you with a usable schema.

Load demo data (optional)

docker compose run --rm --no-deps \
  -v "$PWD/scripts:/app/scripts:ro" backend python /app/scripts/seed_demo_data.py

Then sign in with demo@traveljournal.app / DemoTraveller2026.

Stop / reset

docker compose down            # stop, keep data
docker compose down -v         # stop and delete the database volume

Project layout

All application code lives under a src/ directory. Tests, migrations, config and scripts sit outside it.

.
├── backend/
│   ├── src/app/                 ← all backend source
│   │   ├── main.py                  app factory, middleware, health checks
│   │   ├── core/                    settings, security, logging, errors, rate limits
│   │   ├── db/                      engine, session lifecycle, declarative base
│   │   ├── models/                  SQLAlchemy tables
│   │   ├── schemas/                 Pydantic request/response contracts
│   │   ├── services/                business logic (routers stay thin)
│   │   └── api/v1/                  HTTP routes
│   ├── tests/                   ← outside src
│   ├── migrations/              ← Alembic, outside src
│   ├── pyproject.toml
│   └── Dockerfile
│
├── frontend/
│   ├── src/                     ← all frontend source
│   │   ├── main.tsx, App.tsx        entry and routing
│   │   ├── pages/                   one file per screen
│   │   ├── components/
│   │   │   ├── ui/                  design-system primitives
│   │   │   ├── layout/              shell, navbar, route guards
│   │   │   ├── trips/               trip card, map, form editors
│   │   │   └── charts/              chart primitives
│   │   ├── hooks/                   TanStack Query hooks
│   │   ├── context/                 auth/session state
│   │   ├── lib/                     API client, formatting, validation, tokens
│   │   └── types/                   API type mirror
│   ├── tests/                   ← outside src
│   ├── Dockerfile, nginx.conf
│   └── tailwind.config.js
│
├── scripts/                     ← seed + one-shot MongoDB migration
├── docker-compose.yml           ← development
├── docker-compose.prod.yml      ← production
└── .env.example

Security

The auth design assumes the frontend can be compromised by XSS and aims to limit the damage.

Passwords are hashed with Argon2id (64 MB memory, 3 passes, unique salt). Registration enforces a minimum of 10 characters with upper, lower and numeric characters, and rejects the common credential-stuffing list. Hashes are transparently upgraded on login if the cost parameters change.

Access tokens are short-lived (15 min) JWTs held in memory only. Nothing authentication-related is written to localStorage, because anything JavaScript can read, an injected script can read too.

Refresh tokens are opaque 288-bit random strings delivered as httpOnly, SameSite cookies scoped to the auth routes. Only a SHA-256 hash is stored, so a database dump cannot be replayed against the API.

Rotation with reuse detection. Every refresh invalidates the token it consumed and issues a successor in the same family. If a token that was already spent is presented again, that means it leaked — so the entire family is revoked and the session dies. Concurrent requests on the client share a single in-flight refresh, so a dashboard firing five requests at once cannot trip this by accident.

Ownership isolation. Every query is scoped by user_id. Another account's trip returns 404, not 403 — a 403 would confirm the record exists.

Also in place: per-account/per-IP rate limits on login, registration and uploads; generic login errors plus a dummy hash verification so the endpoint cannot be used to enumerate accounts; a 403 on inactive accounts; all sessions revoked on password change; uploads validated by decoding the image bytes rather than trusting the extension or Content-Type, stored under random names in per-user directories, with path-traversal rejected on delete; security headers and HSTS; strict CORS; and a settings validator that refuses to boot production with a development secret, an insecure cookie flag, or a wildcard origin.


Testing

Backend — 76 tests against a real PostgreSQL database, because the schema uses ARRAY columns and to_char; a SQLite suite would pass while production broke. Each test runs in a transaction that is rolled back.

docker compose exec -T db psql -U travel -d postgres -c "CREATE DATABASE traveljournal_test;"

docker compose run --rm --no-deps \
  -e TEST_DATABASE_URL="postgresql+asyncpg://travel:travel@db:5432/traveljournal_test" \
  -e ENVIRONMENT=test \
  -v "$PWD/backend/tests:/app/tests:ro" backend python -m pytest -q

Coverage includes password hashing, the full rotation/reuse-detection flow, account enumeration resistance, rate limiting, per-user isolation on every endpoint, upload validation (including a PHP payload renamed to .png), derived-field arithmetic, and a regression test for an analytics query that once double-counted every total.

Frontend

docker compose run --rm --no-deps frontend npx vitest run
docker compose run --rm --no-deps frontend npx tsc --noEmit

Deploying

cp .env.example .env       # then fill in POSTGRES_PASSWORD, SECRET_KEY, and the URLs
docker compose -f docker-compose.prod.yml up -d --build

The production stack differs in ways that matter:

  • Only nginx publishes a port. Postgres and the API stay on the internal network.
  • The frontend is a static build served by nginx, which also proxies /api — so the API is same-origin, there is no CORS preflight, and the refresh cookie is first-party.
  • The API runs as a non-root user with 4 workers behind --proxy-headers.
  • Interactive API docs are disabled.
  • The app refuses to start if SECRET_KEY is a development default, COOKIE_SECURE is false, or CORS_ORIGINS contains a wildcard.

Put TLS in front of nginx (a reverse proxy or a load balancer). COOKIE_SECURE=true means the refresh cookie will not be sent over plain HTTP, so sessions will not persist without it.

Rate limiting at scale

Rate-limit counters are in-process. Running more than one API container makes each one enforce its own budget — point storage_uri in backend/src/app/core/rate_limit.py at Redis before scaling out.


Migrating from the old MongoDB version

The previous version had no accounts, so all documents are assigned to one user you nominate. Existing photo files keep working: they are still served from /uploads, and only the URL host is rewritten.

# Preview without writing anything
docker compose run --rm --no-deps \
  -e MONGO_URL=mongodb://host.docker.internal:27017 \
  -e OWNER_EMAIL=you@example.com -e OWNER_PASSWORD='YourStrongPass1' \
  -v "$PWD/scripts:/app/scripts:ro" \
  backend sh -c "pip install -q motor==3.6.0 && python /app/scripts/migrate_from_mongo.py --dry-run"

Drop --dry-run to commit. It is safe to re-run — a trip is skipped if the owner already has one with the same title and start date. The script also repairs legacy data: free-text dates are parsed across several formats, 0.0 coordinates (which meant "unset") become NULL, expense categories are inferred from item names, comma-joined lists are split and de-duplicated, and every trip total is recomputed from its line items rather than trusted.


API

Swagger UI is at /docs in development. Every endpoint below except register/login/refresh requires Authorization: Bearer <access_token>.

POST   /api/v1/auth/register           create an account and sign in
POST   /api/v1/auth/login              sign in
POST   /api/v1/auth/refresh            rotate the refresh cookie, get a new access token
POST   /api/v1/auth/logout             end this session
POST   /api/v1/auth/logout-all         end every session
GET    /api/v1/auth/me                 the signed-in user
POST   /api/v1/auth/change-password    change password, revoking all sessions

GET    /api/v1/users/me                profile
PATCH  /api/v1/users/me                update profile

GET    /api/v1/trips                   list — search, filter, sort, paginate
POST   /api/v1/trips                   create (with expenses, companions, photos)
GET    /api/v1/trips/map               only trips that have coordinates
GET    /api/v1/trips/{id}              one trip, in full
PATCH  /api/v1/trips/{id}              partial update
POST   /api/v1/trips/{id}/favourite    toggle favourite
DELETE /api/v1/trips/{id}              delete (cascades to children)

GET    /api/v1/wishlist                list
POST   /api/v1/wishlist                add a destination
PATCH  /api/v1/wishlist/{id}           update
DELETE /api/v1/wishlist/{id}           remove

POST   /api/v1/uploads                 one photo
POST   /api/v1/uploads/batch           up to 20 photos
GET    /api/v1/uploads/limits          constraints for the client to pre-check

GET    /api/v1/stats                   statistics page data
GET    /api/v1/stats/expenses          expense analytics
GET    /api/v1/stats/overview          timeline summary strip
GET    /api/v1/gallery                 every photo, flattened with trip context

GET    /health                         liveness
GET    /health/ready                   readiness (503 when the database is down)

Errors always use the same envelope, so the client never special-cases an endpoint:

{ "error": { "code": "not_found", "message": "Trip not found", "details": {} } }

details carries per-field messages on validation failures, which the forms map straight onto the offending inputs.


Notes on some decisions

Money is never a float. Amounts are NUMERIC(12,2) in Postgres, Decimal in Python, and strings in JSON. 0.1 + 0.2 != 0.3 in binary floating point, and a journal that silently loses paise is worse than one that is slightly more annoying to parse. toNumber() in frontend/src/lib/format.ts is the single place that converts.

Analytics are computed in SQL. The old frontend fetched every trip and reduced in JavaScript. That is fine for ten trips and wasteful for a thousand, so the aggregation moved into the database and each page loads from one response.

Charts use one colour, not seven. The category breakdown is a ranked bar list where each row is labelled with its name and amount — so bar length carries the magnitude and colour has no job to do. A seven-hue categorical palette was tried first and failed colour-vision separation (purple and blue were indistinguishable under deuteranopia); since the rows are directly labelled, it was also redundant. Every chart ships a screen-reader <table> of the same numbers.

Derived values are stored, not recomputed per read. num_days and total_expense are written on every mutation. The trip list would otherwise recompute them for every row on every request.

The photo requirement is gone. The old form refused to save without a photo, which blocked recording a trip you had no pictures of. Only title, location and start date are required now.


Next up

Agent-driven journalling: a chat interface where describing a trip in prose creates and edits the entries directly. The groundwork is already here — a typed relational schema with real constraints, a service layer that owns every write, and per-user scoping enforced below the HTTP layer, so an agent's tool calls land on the same validated paths as the UI rather than reaching into the database.

About

It is an application where users can able to store their past travel experiences, future plans at one place.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages