Vaka.pro 1bb0d0814c Merge remote-tracking branch 'origin/main' into refactor/frontend-bem-classes
# Conflicts:
#	apps/frontend/src/components/Layout/Header.tsx
#	apps/frontend/src/pages/PublicProfilePage.tsx
2026-04-27 20:47:08 +03:00

Family Wishlist

A small, private wishlist app for two users. Each user has their own profile, slug, and independent wishlist. Guests see a public page at /u/<slug> and can view active and fulfilled wishes.

  • Backend: Node.js 20, Fastify 4, Prisma 5, PostgreSQL 16
  • Frontend: React 18, Vite 5, Tailwind CSS, TanStack Query, React Hook Form + Zod
  • Monorepo: pnpm workspaces
  • Deploy: Docker Compose (shared Postgres + backend + nginx-served frontend)

Features

  • Two fixed accounts (credentials in env), password stored only as a bcrypt hash.
  • Wish entity: title, price, currency, link, comment, image (auto-fetched from link or uploaded).
  • Statuses: Active → Archive / Fulfilled → Trash (kept for 30 days, then permanently removed by a daily cron job).
  • Restore from archive or trash. Duplicate from fulfilled into a new active wish.
  • Badges on cards:
    • owner: new for wishes under 5 days old; fulfilled, archived, trash states.
    • guest: new if this guest hasn't seen the wish yet (tracked by a cookie-based guestId); fulfilled wishes look visually dimmed.
  • Archive and trash are visible only to the owner.
  • Profile page at /u/:slug, public and readable by anyone with the link.

Repository layout

apps/
  backend/    Fastify API, Prisma schema, jobs, scripts
  frontend/   React SPA
packages/
  shared/     zod schemas + DTO types shared between backend and frontend
docker/       Dockerfiles + nginx.conf
docker-compose.yml        prod stack (shared postgres + backend + frontend)
docker-compose.dev.yml    dev helper (postgres only)
.env.example  full env template

Backend layout (apps/backend/src)

config/env.ts                    zod-validated process.env + resolveUsers()
auth/users.registry.ts           in-memory user registry, single source of truth for credentials
plugins/                         fastify plugins (auth, cors, cookie, jwt, rate-limit, static, guest, multipart, prisma)
modules/
  auth/     login / logout / me
  profile/  GET / PATCH current user's profile
  wishes/   CRUD + archive/complete/restore/duplicate + soft-delete
  images/   OG-image fetcher + multipart upload + reset
  public/   public profile and wishes + guest view tracking
  meta/     /api/health + /api/version
jobs/purge-trash.ts              node-cron daily purge of wishes older than 30 days
utils/                           errors, bcrypt helpers, version helper

Database (Prisma)

See apps/backend/prisma/schema.prisma. Tables:

  • Userid, username, slug, displayName, bio, avatarUrl. No password hash.
  • WishuserId, title, price, currency, url, comment, imageUrl, imageSource, status, timestamps, sourceWishId (for duplicates).
  • GuestView(guestId, wishId) pairs for "already seen" tracking.

Password storage

The plain password is never stored in this application — not in the database, not in env, not in logs. Storage split:

Value Location
Original password Owner's password manager only
bcrypt hash .env (USER1_PASSWORD_HASH, USER2_PASSWORD_HASH)
Users registry Built from env on process start, lives in RAM
DB Public fields only (username, slug, displayName)
JWT { id, username } — no credentials

Verification flow (see apps/backend/src/modules/auth/auth.service.ts):

  1. Client POST /api/auth/login with { username, password } over HTTPS.
  2. Server looks up user in the in-memory registry by username.
  3. bcrypt.compare(password, user.passwordHash) runs unconditionally (dummy compare if the username is unknown) — this prevents username enumeration via response timing.
  4. On success: signed JWT set as an httpOnly cookie (fw_auth). Rate limit: 5 logins / 10 minutes per IP.
  5. Fastify logger redacts req.body.password, req.headers.cookie, req.headers.authorization.

Getting started

Prerequisites

  • Node.js 20+
  • pnpm 9+ (corepack enable && corepack prepare pnpm@9.12.0 --activate)
  • Docker + Docker Compose (for the full stack)
  • OpenSSL (for generating secrets)

1. Initialize git + install deps

git init -b main
pnpm install

2. Prepare the environment file

cp .env.example .env

Generate two strong secrets:

openssl rand -hex 32   # paste into JWT_SECRET
openssl rand -hex 32   # paste into COOKIE_SECRET

Generate a bcrypt hash for each user's password (the password is only present in your shell history — clear it afterwards if you care):

pnpm hash-password "Alice's strong password"
# => $2b$12$eImiTXuWV...   copy this into USER1_PASSWORD_HASH

pnpm hash-password "Bob's strong password"
# => $2b$12$aBcDeFgHi...   copy this into USER2_PASSWORD_HASH

Review the rest of .env:

  • USER1_USERNAME, USER1_SLUG, USER1_DISPLAY_NAME
  • USER2_USERNAME, USER2_SLUG, USER2_DISPLAY_NAME
  • DB_HOST=postgres_budget, DB_PORT=5432, DB_NAME=db_family
  • DB_USER, DB_PASSWORD, DATABASE_URL (sensitive values stay only in .env)
  • PUBLIC_APP_URL (used for CORS in production)

3. Run the shared Docker stack

docker compose up --build

Opens:

Before first start, create a dedicated database and user for this project in the existing Postgres host:

  • host: postgres_budget
  • port: 5432
  • database: db_family
  • user/password: set only in .env and do not commit them

On first start, the backend:

  1. Runs prisma db push against the configured shared Postgres database (creates tables from schema.prisma; idempotent).
  2. Seeds/upserts both users from env (public fields only — password hash stays in env).
  3. Starts Fastify on port 3000.
  4. Registers the daily trash-purge cron (runs at 03:17 UTC, also once on startup).

For a stricter migration workflow, switch to prisma migrate dev locally to produce versioned migration files and change the Docker CMD to prisma migrate deploy.

4. Local development (hot reload)

Run Postgres in a container, apps on the host:

docker compose -f docker-compose.dev.yml up -d
# Override DATABASE_URL to point to localhost:
# DATABASE_URL=postgresql://<DB_USER>:<DB_PASSWORD>@localhost:5432/db_family

pnpm --filter @family-wishlist/backend prisma:push    # apply schema (first time and on schema changes)
pnpm --filter @family-wishlist/backend seed           # upsert two users from env into DB
pnpm dev

This starts both apps in parallel:

The dev compose file stays isolated from the shared postgres_budget instance. Keep production credentials and local credentials in .env, and never hardcode them in compose files or source code.

5. Useful scripts

pnpm typecheck                         # typecheck all workspaces
pnpm format                            # prettier on the whole repo
pnpm hash-password "<password>"        # print bcrypt hash to stdout
pnpm --filter @family-wishlist/backend prisma:studio   # GUI over DB

API

All responses are JSON. Authenticated endpoints require the fw_auth cookie.

Method Path Auth Description
POST /api/auth/login Returns auth user, sets cookie. Rate-limited.
POST /api/auth/logout Clears cookie.
GET /api/auth/me owner Current user.
GET /api/profile owner Own profile.
PATCH /api/profile owner Update slug/displayName/bio/avatar.
GET /api/wishes?status=active|archived|completed|deleted owner List own wishes.
POST /api/wishes owner Create.
GET /api/wishes/:id owner Get own wish.
PATCH /api/wishes/:id owner Update.
DELETE /api/wishes/:id owner Soft-delete (to trash).
POST /api/wishes/:id/archive owner
POST /api/wishes/:id/complete owner
POST /api/wishes/:id/restore owner From archive or trash back to active.
POST /api/wishes/:id/duplicate owner Copy fulfilled wish into a new active one.
POST /api/wishes/:id/image owner Multipart image upload.
POST /api/wishes/:id/image/refresh-og owner Re-fetch OG image from link.
DELETE /api/wishes/:id/image owner Reset to default.
GET /api/public/:slug Public profile.
GET /api/public/:slug/wishes Active + fulfilled wishes. Sets isNewForGuest.
POST /api/public/:slug/views Body { wishIds: [] }. Marks as seen.
GET /api/health, /api/version Meta.

Versioning

Each package has its own version (apps/backend/package.json and apps/frontend/package.json). The app footer shows both:

frontend v0.1.0 · backend v0.1.0

Bump them per semver on each change:

  • patch — bug fixes and small non-breaking tweaks,
  • minor — backward-compatible features,
  • major — breaking changes.

Security notes

  • Cookies are httpOnly, SameSite=Lax, Secure in production.
  • All auth-scoped routes re-check ownership in the service layer (userId vs req.user.id).
  • /api/auth/login is rate-limited; username enumeration is mitigated by a constant-time dummy compare.
  • Image fetcher has a 10s timeout, a 5 MB cap, and accepts only image/jpeg|png|webp|gif.
  • Multipart upload cap: 8 MB, one file per request.
  • Prisma cascades delete GuestView rows when their wish is purged.
  • In production, terminate TLS in front of the nginx container (Caddy / Traefik / cloud load balancer).
Description
No description provided
Readme 266 KiB
Languages
TypeScript 90.8%
CSS 6.3%
Dockerfile 2.3%
HTML 0.5%