Compare commits
7 Commits
a7d5260ce3
...
fix/backen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89f75e6d40 | ||
| e69f53114d | |||
|
|
793f0c3422 | ||
| d99002dc3c | |||
|
|
c49abafc61 | ||
| 4f4f9ff998 | |||
|
|
2adb03ff33 |
14
.env.example
14
.env.example
@@ -1,12 +1,14 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# Database
|
# Database
|
||||||
# ==========================================
|
# ==========================================
|
||||||
POSTGRES_USER=wishlist
|
DB_HOST=postgres_budget
|
||||||
POSTGRES_PASSWORD=change_me
|
DB_PORT=5432
|
||||||
POSTGRES_DB=family_wishlist
|
DB_NAME=db_family
|
||||||
# DATABASE_URL uses the docker-compose service name `postgres`.
|
DB_USER=
|
||||||
# When running backend outside docker against docker-postgres use localhost:5432.
|
DB_PASSWORD=
|
||||||
DATABASE_URL=postgresql://wishlist:change_me@postgres:5432/family_wishlist
|
# Fill DATABASE_URL explicitly; .env files do not expand ${...} automatically for the app.
|
||||||
|
# For local host-based development, point it to localhost:5432 instead of postgres_budget.
|
||||||
|
DATABASE_URL=postgresql://<db_user>:<db_password>@postgres_budget:5432/db_family
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Users (two fixed accounts)
|
# Users (two fixed accounts)
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -5,7 +5,7 @@ A small, private wishlist app for two users. Each user has their own profile, sl
|
|||||||
- **Backend**: Node.js 20, Fastify 4, Prisma 5, PostgreSQL 16
|
- **Backend**: Node.js 20, Fastify 4, Prisma 5, PostgreSQL 16
|
||||||
- **Frontend**: React 18, Vite 5, Tailwind CSS, TanStack Query, React Hook Form + Zod
|
- **Frontend**: React 18, Vite 5, Tailwind CSS, TanStack Query, React Hook Form + Zod
|
||||||
- **Monorepo**: pnpm workspaces
|
- **Monorepo**: pnpm workspaces
|
||||||
- **Deploy**: Docker Compose (Postgres + backend + nginx-served frontend)
|
- **Deploy**: Docker Compose (shared Postgres + backend + nginx-served frontend)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ apps/
|
|||||||
packages/
|
packages/
|
||||||
shared/ zod schemas + DTO types shared between backend and frontend
|
shared/ zod schemas + DTO types shared between backend and frontend
|
||||||
docker/ Dockerfiles + nginx.conf
|
docker/ Dockerfiles + nginx.conf
|
||||||
docker-compose.yml prod stack (postgres + backend + frontend)
|
docker-compose.yml prod stack (shared postgres + backend + frontend)
|
||||||
docker-compose.dev.yml dev helper (postgres only)
|
docker-compose.dev.yml dev helper (postgres only)
|
||||||
.env.example full env template
|
.env.example full env template
|
||||||
```
|
```
|
||||||
@@ -127,10 +127,11 @@ Review the rest of `.env`:
|
|||||||
|
|
||||||
- `USER1_USERNAME`, `USER1_SLUG`, `USER1_DISPLAY_NAME`
|
- `USER1_USERNAME`, `USER1_SLUG`, `USER1_DISPLAY_NAME`
|
||||||
- `USER2_USERNAME`, `USER2_SLUG`, `USER2_DISPLAY_NAME`
|
- `USER2_USERNAME`, `USER2_SLUG`, `USER2_DISPLAY_NAME`
|
||||||
- `POSTGRES_*` (used by Docker)
|
- `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)
|
- `PUBLIC_APP_URL` (used for CORS in production)
|
||||||
|
|
||||||
### 3. Run everything via Docker
|
### 3. Run the shared Docker stack
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
@@ -139,12 +140,18 @@ docker compose up --build
|
|||||||
Opens:
|
Opens:
|
||||||
|
|
||||||
- Frontend: http://localhost:8080
|
- Frontend: http://localhost:8080
|
||||||
- Backend API: http://localhost:8080/api (proxied by nginx) or http://localhost:3000 if you map `backend`
|
- Backend API: http://localhost:8080/api (proxied by nginx) or http://localhost:3000 if you map `family-wishlist-backend`
|
||||||
- Postgres: internal only
|
|
||||||
|
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:
|
On first start, the backend:
|
||||||
|
|
||||||
1. Runs `prisma db push` against the Postgres service (creates tables from `schema.prisma`; idempotent).
|
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).
|
2. Seeds/upserts both users from env (public fields only — password hash stays in env).
|
||||||
3. Starts Fastify on port 3000.
|
3. Starts Fastify on port 3000.
|
||||||
4. Registers the daily trash-purge cron (runs at 03:17 UTC, also once on startup).
|
4. Registers the daily trash-purge cron (runs at 03:17 UTC, also once on startup).
|
||||||
@@ -159,7 +166,7 @@ Run Postgres in a container, apps on the host:
|
|||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
# Override DATABASE_URL to point to localhost:
|
# Override DATABASE_URL to point to localhost:
|
||||||
# DATABASE_URL=postgresql://wishlist:change_me@localhost:5432/family_wishlist
|
# 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 prisma:push # apply schema (first time and on schema changes)
|
||||||
pnpm --filter @family-wishlist/backend seed # upsert two users from env into DB
|
pnpm --filter @family-wishlist/backend seed # upsert two users from env into DB
|
||||||
@@ -170,6 +177,9 @@ This starts both apps in parallel:
|
|||||||
|
|
||||||
- Frontend: http://localhost:5173 (proxying `/api` and `/uploads` to http://localhost:3000)
|
- Frontend: http://localhost:5173 (proxying `/api` and `/uploads` to http://localhost:3000)
|
||||||
- Backend: http://localhost:3000
|
- Backend: http://localhost:3000
|
||||||
|
- Dev Postgres: `localhost:5432` for local-only development data
|
||||||
|
|
||||||
|
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
|
### 5. Useful scripts
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
"@prisma/client": "^5.19.1",
|
"@prisma/client": "^5.19.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
"fastify-plugin": "^4.5.1",
|
||||||
"fastify-type-provider-zod": "^2.0.0",
|
"fastify-type-provider-zod": "^2.0.0",
|
||||||
"nanoid": "^5.0.7",
|
"nanoid": "^5.0.7",
|
||||||
"node-cron": "^3.0.3",
|
"node-cron": "^3.0.3",
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ interface DownloadResult {
|
|||||||
contentType: string;
|
contentType: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOgImageUrl(ogImage: unknown): string | undefined {
|
||||||
|
const entry = Array.isArray(ogImage) ? ogImage[0] : ogImage;
|
||||||
|
if (!entry || typeof entry !== 'object') return undefined;
|
||||||
|
const { url } = entry as { url?: unknown };
|
||||||
|
return typeof url === 'string' ? url : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async function downloadImage(url: string): Promise<DownloadResult | null> {
|
async function downloadImage(url: string): Promise<DownloadResult | null> {
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -27,7 +34,7 @@ async function downloadImage(url: string): Promise<DownloadResult | null> {
|
|||||||
headers: { 'user-agent': 'FamilyWishlistBot/1.0 (+image-fetch)' },
|
headers: { 'user-agent': 'FamilyWishlistBot/1.0 (+image-fetch)' },
|
||||||
});
|
});
|
||||||
if (res.statusCode >= 400) return null;
|
if (res.statusCode >= 400) return null;
|
||||||
const contentType = (res.headers['content-type']?.toString() ?? '').split(';')[0].trim();
|
const contentType = ((res.headers['content-type']?.toString() ?? '').split(';')[0] ?? '').trim();
|
||||||
if (!ALLOWED_MIME.has(contentType)) return null;
|
if (!ALLOWED_MIME.has(contentType)) return null;
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
@@ -60,8 +67,7 @@ export async function fetchOgImageForWish(
|
|||||||
try {
|
try {
|
||||||
const parsed = await ogs({ url: pageUrl, timeout: FETCH_TIMEOUT_MS });
|
const parsed = await ogs({ url: pageUrl, timeout: FETCH_TIMEOUT_MS });
|
||||||
if (parsed.error || !parsed.result) return;
|
if (parsed.error || !parsed.result) return;
|
||||||
const imageEntry = parsed.result.ogImage;
|
const imageUrl = getOgImageUrl(parsed.result.ogImage);
|
||||||
const imageUrl = Array.isArray(imageEntry) ? imageEntry[0]?.url : imageEntry?.url;
|
|
||||||
if (!imageUrl) return;
|
if (!imageUrl) return;
|
||||||
|
|
||||||
const absolute = new URL(imageUrl, pageUrl).toString();
|
const absolute = new URL(imageUrl, pageUrl).toString();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import fp from 'fastify-plugin';
|
import fp from 'fastify-plugin';
|
||||||
import fastifyJwt from '@fastify/jwt';
|
import fastifyJwt from '@fastify/jwt';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import type { FastifyReply } from 'fastify';
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
import { UnauthorizedError } from '../utils/errors.js';
|
import { UnauthorizedError } from '../utils/errors.js';
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export default fp(async (app) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// helpers for routes
|
// helpers for routes
|
||||||
app.decorate('setAuthCookie', ((reply, token) => {
|
app.decorate('setAuthCookie', ((reply: FastifyReply, token: string) => {
|
||||||
reply.setCookie(AUTH_COOKIE, token, {
|
reply.setCookie(AUTH_COOKIE, token, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: env.NODE_ENV === 'production',
|
secure: env.NODE_ENV === 'production',
|
||||||
@@ -38,7 +39,7 @@ export default fp(async (app) => {
|
|||||||
});
|
});
|
||||||
}) as never);
|
}) as never);
|
||||||
|
|
||||||
app.decorate('clearAuthCookie', ((reply) => {
|
app.decorate('clearAuthCookie', ((reply: FastifyReply) => {
|
||||||
reply.clearCookie(AUTH_COOKIE, { path: '/' });
|
reply.clearCookie(AUTH_COOKIE, { path: '/' });
|
||||||
}) as never);
|
}) as never);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"rootDir": "./src",
|
"rootDir": "./",
|
||||||
"outDir": "./dist"
|
"outDir": "./dist"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "prisma/seed.ts"],
|
||||||
"exclude": ["**/*.test.ts", "dist"]
|
"exclude": ["**/*.test.ts", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createBrowserRouter } from 'react-router-dom';
|
import { createBrowserRouter, type RouterProviderProps } from 'react-router-dom';
|
||||||
import { ProtectedRoute } from './components/Layout/ProtectedRoute';
|
import { ProtectedRoute } from './components/Layout/ProtectedRoute';
|
||||||
import { AppShell } from './components/Layout/AppShell';
|
import { AppShell } from './components/Layout/AppShell';
|
||||||
import { LoginPage } from './pages/LoginPage';
|
import { LoginPage } from './pages/LoginPage';
|
||||||
@@ -10,7 +10,7 @@ import { ProfileSettingsPage } from './pages/ProfileSettingsPage';
|
|||||||
import { PublicProfilePage } from './pages/PublicProfilePage';
|
import { PublicProfilePage } from './pages/PublicProfilePage';
|
||||||
import { NotFoundPage } from './pages/NotFoundPage';
|
import { NotFoundPage } from './pages/NotFoundPage';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router: RouterProviderProps['router'] = createBrowserRouter([
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
{ path: '/u/:slug', element: <PublicProfilePage /> },
|
{ path: '/u/:slug', element: <PublicProfilePage /> },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
|
container_name: family-wishlist-postgres-dev
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${DB_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
POSTGRES_DB: ${DB_NAME}
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,33 +1,16 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
family-wishlist-backend:
|
||||||
image: postgres:16-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB}
|
|
||||||
volumes:
|
|
||||||
- pgdata:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/backend.Dockerfile
|
dockerfile: docker/backend.Dockerfile
|
||||||
|
container_name: family-wishlist-backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
UPLOADS_DIR: /app/apps/backend/uploads
|
UPLOADS_DIR: /app/apps/backend/uploads
|
||||||
BACKEND_PORT: 3000
|
BACKEND_PORT: 3000
|
||||||
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
volumes:
|
||||||
- uploads:/app/apps/backend/uploads
|
- uploads:/app/apps/backend/uploads
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -42,18 +25,26 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- postgres_default
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/frontend.Dockerfile
|
dockerfile: docker/frontend.Dockerfile
|
||||||
|
container_name: family-wishlist-frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
backend:
|
family-wishlist-backend:
|
||||||
condition: service_started
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "8080:80"
|
||||||
|
networks:
|
||||||
|
- postgres_default
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
|
||||||
uploads:
|
uploads:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
postgres_default:
|
||||||
|
external: true
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ WORKDIR /app/apps/backend
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
# Apply schema (idempotent; uses `db push` so no prior migrations required) +
|
# Apply schema (idempotent; uses `db push` so no prior migrations required) +
|
||||||
# seed env users + start server.
|
# seed env users + start server.
|
||||||
CMD ["sh", "-c", "pnpm exec prisma db push --accept-data-loss --skip-generate && pnpm seed && node dist/index.js"]
|
CMD ["sh", "-c", "pnpm exec prisma db push --accept-data-loss --skip-generate && node dist/prisma/seed.js && node dist/src/index.js"]
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ server {
|
|||||||
|
|
||||||
# API proxy
|
# API proxy
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:3000/api/;
|
proxy_pass http://family-wishlist-backend:3000/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -25,7 +25,7 @@ server {
|
|||||||
|
|
||||||
# Uploaded files (images)
|
# Uploaded files (images)
|
||||||
location /uploads/ {
|
location /uploads/ {
|
||||||
proxy_pass http://backend:3000/uploads/;
|
proxy_pass http://family-wishlist-backend:3000/uploads/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_cache_valid 200 1h;
|
proxy_cache_valid 200 1h;
|
||||||
|
|||||||
3647
pnpm-lock.yaml
generated
Normal file
3647
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user