From 627219df58fee6163d7910315911e3a63501007b Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 16 May 2026 22:40:42 +0330 Subject: [PATCH] bugfix: .env.example files updated. some minor changes in jwt strategy to avoid failing when .env files does not contain needed keys. --- backend/.env.example | 30 +++++++-- backend/README.md | 66 +++++++++++++++++-- backend/docker-compose.postgres.yml | 29 ++++++++ backend/src/configs/configurations.ts | 59 +++++++++++++++-- backend/src/modules/auth/auth.service.ts | 40 ++++++----- .../modules/auth/strategies/jwt.strategy.ts | 2 +- frontend/.env.example | 7 ++ frontend/.gitignore | 4 +- frontend/package.json | 2 +- infrastructure/.env.example | 8 ++- infrastructure/backend.staging.env.example | 1 + 11 files changed, 209 insertions(+), 39 deletions(-) create mode 100644 backend/docker-compose.postgres.yml create mode 100644 frontend/.env.example diff --git a/backend/.env.example b/backend/.env.example index b6276ca..77e74a8 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,23 +1,41 @@ # Database -DATABASE_URL=postgresql://dyolink_user:password@postgres:5432/dyolink_db -POSTGRES_HOST=postgres +# +# Local dev (Nest on your machine + Postgres via docker-compose.postgres.yml): +# Use host "localhost" — hostname "postgres" only works inside Docker networks. +DATABASE_URL=postgresql://dyolink_user:password@localhost:5432/dyolink_db +POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=dyolink_user -POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION +POSTGRES_PASSWORD=password POSTGRES_DB=dyolink_db +# +# If you run the API inside the same Compose stack as Postgres, use instead: +# DATABASE_URL=postgresql://dyolink_user:password@postgres:5432/dyolink_db +# POSTGRES_HOST=postgres -# JWT +# JWT (required for register/login) JWT_SECRET=CHANGE_ME_TO_A_STRONG_SECRET_32_CHARS_MIN JWT_EXPIRES_IN=7d +JWT_REFRESH_SECRET=CHANGE_ME_TO_ANOTHER_STRONG_SECRET +JWT_REFRESH_EXPIRES_IN=30d # Application PORT=3000 NODE_ENV=development API_PREFIX=/api -CORS_ORIGIN=http://localhost:3000 +# CORS and invite links — must match the URL where the Next.js app runs +FRONTEND_URL=http://localhost:3001 + +# OAuth (optional — uncomment when configured) +# GOOGLE_CLIENT_ID=your-google-client-id +# GOOGLE_CLIENT_SECRET=your-google-client-secret +# GOOGLE_CALLBACK_URL=http://localhost:3000/auth/google/callback +# FACEBOOK_CLIENT_ID=your-facebook-app-id +# FACEBOOK_CLIENT_SECRET=your-facebook-app-secret +# FACEBOOK_CALLBACK_URL=http://localhost:3000/auth/facebook/callback # Email (configure for production) SMTP_HOST=smtp.gmail.com SMTP_PORT=587 SMTP_USER=your_email@gmail.com -SMTP_PASSWORD=your_app_password \ No newline at end of file +SMTP_PASSWORD=your_app_password diff --git a/backend/README.md b/backend/README.md index 4fda133..b44f12f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -3,7 +3,7 @@ ## Prerequisites - **Node.js 20+** and **npm** -- **PostgreSQL** reachable from your machine (local or remote) +- **PostgreSQL** reachable from your machine — either installed locally **or** run via Docker (see below) ## First-time setup @@ -28,9 +28,23 @@ - `JWT_SECRET` — strong secret for signing tokens Do not commit `.env`. -4. **Database URL for local dev** +4. **Database for local dev** - Point `DATABASE_URL` at a database you created in Postgres (create an empty DB first if needed). + **Option A — Postgres in Docker (no local install, e.g. Mac)** + From `backend/`, with `.env` present (copy from `.env.example` first): + + - Ensure `DATABASE_URL` uses **`localhost`** as the host (not `postgres`). Match user, password, and DB name to `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in the same file. + + ```bash + docker compose -f docker-compose.postgres.yml up -d + ``` + + Wait until Postgres is healthy (`docker compose -f docker-compose.postgres.yml ps`). The container creates the database on first start. + + To stop Postgres (data is kept in the named volume): `docker compose -f docker-compose.postgres.yml down` + + **Option B — Postgres installed on the machine** + Create an empty database, then point `DATABASE_URL` at it. 5. **Generate Prisma Client** @@ -46,12 +60,43 @@ This runs `prisma migrate dev`. Use it during development when the schema changes. -7. **Seed** (optional — sample data / bootstrap) +7. **Seed** (optional — reference data only) ```bash npm run prisma:seed ``` + This **does not** wipe your database. It only upserts lookup data: organization types (`CLINIC`, `LAB`), subscription plans, and tab permissions. Existing users, organizations, memberships, patients, appointments, and links are **left unchanged**. + + To start from an empty database with fresh tables and reference data, see [Reset database (clean slate)](#reset-database-clean-slate) below. + +## Reset database (clean slate) + +Use this when you want to **delete all application data** (users, organizations, patients, sessions, etc.) and rebuild the schema from migrations, then run the seed. + +From `backend/`: + +```bash +npx prisma migrate reset +``` + +Prisma will prompt for confirmation, drop the database, re-apply all migrations, and run `prisma/seed.ts` automatically. + +**What gets removed:** everything in the database, including organizations and all related rows. + +**What the seed adds back:** only reference data (types, plans, permissions) — not demo users or organizations. Register again or use your own test data after a reset. + +**Docker Postgres dev:** if you also want to wipe the Docker volume (not only tables), stop the container and remove the volume: + +```bash +docker compose -f docker-compose.postgres.yml down -v +docker compose -f docker-compose.postgres.yml up -d +npm run prisma:migrate +npm run prisma:seed +``` + +Do **not** run `migrate reset` against production or shared staging databases. + ## Run (development) ```bash @@ -60,7 +105,7 @@ npm run start:dev API listens on **`http://localhost:3000`** by default (`PORT` in `.env`). -If the frontend runs on another origin (e.g. `http://localhost:3001`), set `CORS_ORIGIN` in `.env` to that URL. +If the frontend runs on another origin (e.g. `http://localhost:3001`), set `FRONTEND_URL` in `.env` to that URL (CORS and invite links use it). ## After pulling latest `main` @@ -71,7 +116,7 @@ npm run prisma:generate npm run prisma:migrate ``` -If teammates added migrations, step 4 applies them. Resolve migration conflicts locally before pushing. +If teammates added migrations, the migrate step above applies them. Resolve migration conflicts locally before pushing. ## Useful commands @@ -80,9 +125,16 @@ If teammates added migrations, step 4 applies them. Resolve migration conflicts | `npm run prisma:generate` | Regenerate client after `schema.prisma` changes | | `npm run prisma:migrate` | Dev migrations (`migrate dev`) | | `npm run prisma:deploy` | Production-style apply (`migrate deploy`) — e.g. CI/containers | +| `npm run prisma:seed` | Upsert reference data only (does not clear existing rows) | +| `npx prisma migrate reset` | Drop DB, re-migrate, run seed — **dev clean slate** | | `npm run build` | Compile Nest app | | `npm run start:prod` | Run compiled app (`node dist/main`) | ## Docker -Image build is defined in **`Dockerfile`** at this folder. For full-stack deployment and CI, see the **repository root `README.md`**. +| File | Purpose | +|------|--------| +| **`Dockerfile`** | Production API image | +| **`docker-compose.postgres.yml`** | Local dev Postgres only (port mapped to host) | + +For full-stack deployment and CI, see the **repository root `README.md`**. diff --git a/backend/docker-compose.postgres.yml b/backend/docker-compose.postgres.yml new file mode 100644 index 0000000..405d992 --- /dev/null +++ b/backend/docker-compose.postgres.yml @@ -0,0 +1,29 @@ +# Local development Postgres only. +# Run from backend/: docker compose -f docker-compose.postgres.yml up -d +# +# Nest runs on your Mac/PC; use DATABASE_URL with host "localhost" (not "postgres"). +# Variables POSTGRES_* and POSTGRES_PORT are read from .env (see .env.example). + +services: + postgres: + image: postgres:15-alpine + container_name: dyolink-postgres-dev + restart: unless-stopped + ports: + - "${POSTGRES_PORT:-5432}:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER:-dyolink_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-password} + POSTGRES_DB: ${POSTGRES_DB:-dyolink_db} + volumes: + - dyolink_pgdata_dev:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 10s + +volumes: + dyolink_pgdata_dev: + name: dyolink_postgres_data_dev diff --git a/backend/src/configs/configurations.ts b/backend/src/configs/configurations.ts index 2e36ccc..e4d1c38 100644 --- a/backend/src/configs/configurations.ts +++ b/backend/src/configs/configurations.ts @@ -1,4 +1,36 @@ // backend/src/config/configuration.ts + +/** Matches values accepted by jsonwebtoken `expiresIn` (via ms), e.g. 7d, 15m, or plain seconds. */ +const JWT_TIMESPAN_PATTERN = + /^\d+(\.\d+)?(ms|s|m|h|d|w|y)?$/i; + +function assertJwtSecret(value: string, envKey: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`❌ Environment variable ${envKey} is required but not set`); + } + if (trimmed.length < 16) { + throw new Error(`❌ ${envKey} must be at least 16 characters`); + } + if (/CHANGE_ME/i.test(trimmed)) { + throw new Error(`❌ ${envKey} must be changed from the placeholder value`); + } + return trimmed; +} + +function assertJwtTimespan(value: string, envKey: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`❌ Environment variable ${envKey} is required but not set`); + } + if (!/^\d+$/.test(trimmed) && !JWT_TIMESPAN_PATTERN.test(trimmed)) { + throw new Error( + `❌ ${envKey}="${value}" is invalid. Use a duration like 7d, 15m, 30d, or a number of seconds.`, + ); + } + return trimmed; +} + export interface Config { port: number; database: { @@ -7,6 +39,8 @@ export interface Config { jwt: { secret: string; expiresIn: string; + refreshSecret: string; + refreshExpiresIn: string; }; throttle: { ttl: number; @@ -24,9 +58,10 @@ export default (): Config => { return value; }; - // Helper for optional env vars with defaults + // Helper for optional env vars with defaults (whitespace-only counts as unset) const getEnvVarWithDefault = (key: string, defaultValue: string): string => { - return process.env[key] || defaultValue; + const value = process.env[key]?.trim(); + return value ? value : defaultValue; }; const getEnvVarAsNumber = (key: string, defaultValue: number): number => { @@ -36,14 +71,30 @@ export default (): Config => { return isNaN(parsed) ? defaultValue : parsed; }; + const jwtSecret = assertJwtSecret(getEnvVar('JWT_SECRET'), 'JWT_SECRET'); + const jwtExpiresIn = assertJwtTimespan( + getEnvVarWithDefault('JWT_EXPIRES_IN', '7d'), + 'JWT_EXPIRES_IN', + ); + const jwtRefreshSecret = assertJwtSecret( + getEnvVarWithDefault('JWT_REFRESH_SECRET', jwtSecret), + 'JWT_REFRESH_SECRET', + ); + const jwtRefreshExpiresIn = assertJwtTimespan( + getEnvVarWithDefault('JWT_REFRESH_EXPIRES_IN', '30d'), + 'JWT_REFRESH_EXPIRES_IN', + ); + return { port: getEnvVarAsNumber('PORT', 3000), database: { url: getEnvVar('DATABASE_URL'), }, jwt: { - secret: getEnvVar('JWT_SECRET'), - expiresIn: getEnvVarWithDefault('JWT_EXPIRES_IN', '7d'), + secret: jwtSecret, + expiresIn: jwtExpiresIn, + refreshSecret: jwtRefreshSecret, + refreshExpiresIn: jwtRefreshExpiresIn, }, throttle: { ttl: getEnvVarAsNumber('THROTTLE_TTL', 60), diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 368624f..7e3a4a1 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -7,6 +7,7 @@ import { InternalServerErrorException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; +import type { JwtSignOptions } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../../../prisma/prisma.service'; @@ -53,6 +54,20 @@ export class AuthService { private configService: ConfigService, ) { } + private accessJwtSignOptions(): JwtSignOptions { + return { + secret: this.configService.get('jwt.secret')!, + expiresIn: this.configService.get('jwt.expiresIn')!, + } as JwtSignOptions; + } + + private refreshJwtSignOptions(): JwtSignOptions { + return { + secret: this.configService.get('jwt.refreshSecret')!, + expiresIn: this.configService.get('jwt.refreshExpiresIn')!, + } as JwtSignOptions; + } + /** * Validate user credentials (used by LocalStrategy) * @param email - User's email @@ -128,14 +143,8 @@ export class AuthService { }; const [accessToken, refreshToken] = await Promise.all([ - this.jwtService.signAsync(accessPayload, { - secret: this.configService.get('JWT_SECRET'), - expiresIn: this.configService.get('JWT_EXPIRES_IN'), - }), - this.jwtService.signAsync(refreshPayload, { - secret: this.configService.get('JWT_REFRESH_SECRET'), - expiresIn: this.configService.get('JWT_REFRESH_EXPIRES_IN'), - }), + this.jwtService.signAsync(accessPayload, this.accessJwtSignOptions()), + this.jwtService.signAsync(refreshPayload, this.refreshJwtSignOptions()), ]); // Store session in database @@ -398,7 +407,7 @@ export class AuthService { try { // Verify the refresh token const payload = await this.jwtService.verifyAsync(refreshToken, { - secret: this.configService.get('jwt.refreshSecret'), + secret: this.configService.get('jwt.refreshSecret'), }); // Ensure this is a refresh token @@ -446,10 +455,10 @@ export class AuthService { type: 'access', }; - const newAccessToken = await this.jwtService.signAsync(newAccessPayload, { - secret: this.configService.get('jwt.secret'), - expiresIn: this.configService.get('jwt.expiresIn'), - }); + const newAccessToken = await this.jwtService.signAsync( + newAccessPayload, + this.accessJwtSignOptions(), + ); // Update session with new access token await this.prisma.session.update({ @@ -732,10 +741,7 @@ export class AuthService { }; // 3. Generate new token - const accessToken = await this.jwtService.signAsync(payload, { - secret: this.configService.get('JWT_SECRET'), - expiresIn: this.configService.get('JWT_EXPIRES_IN'), - }); + const accessToken = await this.jwtService.signAsync(payload, this.accessJwtSignOptions()); // 4. Format permissions const permissions = this.getMembershipPermissions(membership); diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts index faaaff3..412ad61 100644 --- a/backend/src/modules/auth/strategies/jwt.strategy.ts +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -17,7 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { return req?.cookies?.accessToken; // ✅ READ FROM COOKIE }, ignoreExpiration: false, - secretOrKey: configService.get('JWT_SECRET'), + secretOrKey: configService.get('jwt.secret'), }); } diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..8b36792 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,7 @@ +# Copy to .env.local for local development (Next.js loads .env.local automatically). +# Do not commit .env.local — only this template is tracked in git. + +NEXT_PUBLIC_API_URL=http://localhost:3000/api +NEXT_PUBLIC_APP_NAME=DyoLink +# URL where users open the frontend (used for metadata, images, etc.) +NEXT_PUBLIC_APP_URL=http://localhost:3001 diff --git a/frontend/.gitignore b/frontend/.gitignore index 056cdb2..2bd9440 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -31,7 +31,9 @@ yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) -.env* +.env +.env.* +!.env.example # vercel .vercel diff --git a/frontend/package.json b/frontend/package.json index 2cf888b..f07681e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "scripts": { "dev": "next dev -p 3001", "build": "next build", - "start": "next start -p 3000", + "start": "next start -p 3001", "lint": "next lint" }, "dependencies": { diff --git a/infrastructure/.env.example b/infrastructure/.env.example index d96c8b7..b3c2417 100644 --- a/infrastructure/.env.example +++ b/infrastructure/.env.example @@ -12,9 +12,13 @@ DOMAIN=dyolink.com # Backend Environment (create backend.env from this) # NODE_ENV=production -# JWT_SECRET=CHANGE_THIS_TO_STRONG_SECRET_32_CHARS +# PORT=3000 # DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} -# CORS_ORIGIN=https://dyolink.com +# JWT_SECRET=CHANGE_THIS_TO_STRONG_SECRET_32_CHARS +# JWT_EXPIRES_IN=15m +# JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET +# JWT_REFRESH_EXPIRES_IN=30d +# FRONTEND_URL=https://dyolink.com # Frontend Environment (create frontend.env from this) # NEXT_PUBLIC_API_URL=/api diff --git a/infrastructure/backend.staging.env.example b/infrastructure/backend.staging.env.example index 19f6ab8..9c62d4a 100644 --- a/infrastructure/backend.staging.env.example +++ b/infrastructure/backend.staging.env.example @@ -9,4 +9,5 @@ JWT_EXPIRES_IN=15m JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET JWT_REFRESH_EXPIRES_IN=30d +# CORS, cookies, and invite links — must match how users open the app (nginx host port) FRONTEND_URL=http://178.131.50.201:8088