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:
@@ -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
|
||||
SMTP_PASSWORD=your_app_password
|
||||
|
||||
@@ -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`**.
|
||||
|
||||
29
backend/docker-compose.postgres.yml
Normal file
29
backend/docker-compose.postgres.yml
Normal 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
|
||||
@@ -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),
|
||||
|
||||
@@ -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<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)
|
||||
* @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<string>('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);
|
||||
|
||||
@@ -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<string>('jwt.secret'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
7
frontend/.env.example
Normal file
7
frontend/.env.example
Normal 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
4
frontend/.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user