bugfix: .env.example files updated. some minor changes in jwt strategy to avoid failing when .env files does not contain needed keys.

This commit is contained in:
2026-05-16 22:40:42 +03:30
parent 105175ff4d
commit 627219df58
11 changed files with 209 additions and 39 deletions

View File

@@ -1,23 +1,41 @@
# Database # 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_PORT=5432
POSTGRES_USER=dyolink_user POSTGRES_USER=dyolink_user
POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION POSTGRES_PASSWORD=password
POSTGRES_DB=dyolink_db 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_SECRET=CHANGE_ME_TO_A_STRONG_SECRET_32_CHARS_MIN
JWT_EXPIRES_IN=7d JWT_EXPIRES_IN=7d
JWT_REFRESH_SECRET=CHANGE_ME_TO_ANOTHER_STRONG_SECRET
JWT_REFRESH_EXPIRES_IN=30d
# Application # Application
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development
API_PREFIX=/api 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) # Email (configure for production)
SMTP_HOST=smtp.gmail.com SMTP_HOST=smtp.gmail.com
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER=your_email@gmail.com SMTP_USER=your_email@gmail.com
SMTP_PASSWORD=your_app_password SMTP_PASSWORD=your_app_password

View File

@@ -3,7 +3,7 @@
## Prerequisites ## Prerequisites
- **Node.js 20+** and **npm** - **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 ## First-time setup
@@ -28,9 +28,23 @@
- `JWT_SECRET` — strong secret for signing tokens - `JWT_SECRET` — strong secret for signing tokens
Do not commit `.env`. 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** 5. **Generate Prisma Client**
@@ -46,12 +60,43 @@
This runs `prisma migrate dev`. Use it during development when the schema changes. 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 ```bash
npm run prisma:seed 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) ## Run (development)
```bash ```bash
@@ -60,7 +105,7 @@ npm run start:dev
API listens on **`http://localhost:3000`** by default (`PORT` in `.env`). 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` ## After pulling latest `main`
@@ -71,7 +116,7 @@ npm run prisma:generate
npm run prisma:migrate 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 ## 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:generate` | Regenerate client after `schema.prisma` changes |
| `npm run prisma:migrate` | Dev migrations (`migrate dev`) | | `npm run prisma:migrate` | Dev migrations (`migrate dev`) |
| `npm run prisma:deploy` | Production-style apply (`migrate deploy`) — e.g. CI/containers | | `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 build` | Compile Nest app |
| `npm run start:prod` | Run compiled app (`node dist/main`) | | `npm run start:prod` | Run compiled app (`node dist/main`) |
## Docker ## 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`**.

View File

@@ -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

View File

@@ -1,4 +1,36 @@
// backend/src/config/configuration.ts // 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 { export interface Config {
port: number; port: number;
database: { database: {
@@ -7,6 +39,8 @@ export interface Config {
jwt: { jwt: {
secret: string; secret: string;
expiresIn: string; expiresIn: string;
refreshSecret: string;
refreshExpiresIn: string;
}; };
throttle: { throttle: {
ttl: number; ttl: number;
@@ -24,9 +58,10 @@ export default (): Config => {
return value; 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 => { 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 => { const getEnvVarAsNumber = (key: string, defaultValue: number): number => {
@@ -36,14 +71,30 @@ export default (): Config => {
return isNaN(parsed) ? defaultValue : parsed; 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 { return {
port: getEnvVarAsNumber('PORT', 3000), port: getEnvVarAsNumber('PORT', 3000),
database: { database: {
url: getEnvVar('DATABASE_URL'), url: getEnvVar('DATABASE_URL'),
}, },
jwt: { jwt: {
secret: getEnvVar('JWT_SECRET'), secret: jwtSecret,
expiresIn: getEnvVarWithDefault('JWT_EXPIRES_IN', '7d'), expiresIn: jwtExpiresIn,
refreshSecret: jwtRefreshSecret,
refreshExpiresIn: jwtRefreshExpiresIn,
}, },
throttle: { throttle: {
ttl: getEnvVarAsNumber('THROTTLE_TTL', 60), ttl: getEnvVarAsNumber('THROTTLE_TTL', 60),

View File

@@ -7,6 +7,7 @@ import {
InternalServerErrorException InternalServerErrorException
} from '@nestjs/common'; } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import type { JwtSignOptions } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
@@ -53,6 +54,20 @@ export class AuthService {
private configService: ConfigService, private configService: ConfigService,
) { } ) { }
private accessJwtSignOptions(): JwtSignOptions {
return {
secret: this.configService.get<string>('jwt.secret')!,
expiresIn: this.configService.get<string>('jwt.expiresIn')!,
} as JwtSignOptions;
}
private refreshJwtSignOptions(): JwtSignOptions {
return {
secret: this.configService.get<string>('jwt.refreshSecret')!,
expiresIn: this.configService.get<string>('jwt.refreshExpiresIn')!,
} as JwtSignOptions;
}
/** /**
* Validate user credentials (used by LocalStrategy) * Validate user credentials (used by LocalStrategy)
* @param email - User's email * @param email - User's email
@@ -128,14 +143,8 @@ export class AuthService {
}; };
const [accessToken, refreshToken] = await Promise.all([ const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(accessPayload, { this.jwtService.signAsync(accessPayload, this.accessJwtSignOptions()),
secret: this.configService.get('JWT_SECRET'), this.jwtService.signAsync(refreshPayload, this.refreshJwtSignOptions()),
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'),
}),
]); ]);
// Store session in database // Store session in database
@@ -398,7 +407,7 @@ export class AuthService {
try { try {
// Verify the refresh token // Verify the refresh token
const payload = await this.jwtService.verifyAsync(refreshToken, { const payload = await this.jwtService.verifyAsync(refreshToken, {
secret: this.configService.get('jwt.refreshSecret'), secret: this.configService.get<string>('jwt.refreshSecret'),
}); });
// Ensure this is a refresh token // Ensure this is a refresh token
@@ -446,10 +455,10 @@ export class AuthService {
type: 'access', type: 'access',
}; };
const newAccessToken = await this.jwtService.signAsync(newAccessPayload, { const newAccessToken = await this.jwtService.signAsync(
secret: this.configService.get('jwt.secret'), newAccessPayload,
expiresIn: this.configService.get('jwt.expiresIn'), this.accessJwtSignOptions(),
}); );
// Update session with new access token // Update session with new access token
await this.prisma.session.update({ await this.prisma.session.update({
@@ -732,10 +741,7 @@ export class AuthService {
}; };
// 3. Generate new token // 3. Generate new token
const accessToken = await this.jwtService.signAsync(payload, { const accessToken = await this.jwtService.signAsync(payload, this.accessJwtSignOptions());
secret: this.configService.get('JWT_SECRET'),
expiresIn: this.configService.get('JWT_EXPIRES_IN'),
});
// 4. Format permissions // 4. Format permissions
const permissions = this.getMembershipPermissions(membership); const permissions = this.getMembershipPermissions(membership);

View File

@@ -17,7 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
return req?.cookies?.accessToken; // ✅ READ FROM COOKIE return req?.cookies?.accessToken; // ✅ READ FROM COOKIE
}, },
ignoreExpiration: false, ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'), secretOrKey: configService.get<string>('jwt.secret'),
}); });
} }

7
frontend/.env.example Normal file
View File

@@ -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

4
frontend/.gitignore vendored
View File

@@ -31,7 +31,9 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env
.env.*
!.env.example
# vercel # vercel
.vercel .vercel

View File

@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev -p 3001", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start -p 3000", "start": "next start -p 3001",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {

View File

@@ -12,9 +12,13 @@ DOMAIN=dyolink.com
# Backend Environment (create backend.env from this) # Backend Environment (create backend.env from this)
# NODE_ENV=production # 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} # 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) # Frontend Environment (create frontend.env from this)
# NEXT_PUBLIC_API_URL=/api # NEXT_PUBLIC_API_URL=/api

View File

@@ -9,4 +9,5 @@ JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET
JWT_REFRESH_EXPIRES_IN=30d 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 FRONTEND_URL=http://178.131.50.201:8088