Initial commit: Full project structure

- Backend: NestJS with Docker
- Frontend: Next.js with Docker
- Nginx configuration for reverse proxy
- PostgreSQL setup
- Docker compose for orchestration
- Development environment configuration
This commit is contained in:
2026-04-23 15:33:11 +03:30
commit 26bd35ae3c
94 changed files with 5627 additions and 0 deletions

13
backend/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
dist
.git
.gitignore
.env
.env.*
npm-debug.log
README.md
.DS_Store
coverage
*.log
test
*.spec.ts

23
backend/.env.example Normal file
View File

@@ -0,0 +1,23 @@
# Database
DATABASE_URL=postgresql://dyolink_user:password@postgres:5432/dyolink_db
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=dyolink_user
POSTGRES_PASSWORD=CHANGE_ME_IN_PRODUCTION
POSTGRES_DB=dyolink_db
# JWT
JWT_SECRET=CHANGE_ME_TO_A_STRONG_SECRET_32_CHARS_MIN
JWT_EXPIRES_IN=7d
# Application
PORT=3000
NODE_ENV=development
API_PREFIX=/api
CORS_ORIGIN=http://localhost:3000
# Email (configure for production)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your_email@gmail.com
SMTP_PASSWORD=your_app_password

56
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json

4
backend/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

92
backend/Dockerfile Normal file
View File

@@ -0,0 +1,92 @@
# ============================================
# STAGE 1: BUILDER STAGE
# ============================================
# This stage builds the application and prepares assets
FROM node:18-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json first (for better caching)
COPY package*.json ./
# Copy Prisma schema (needed for Prisma client generation)
COPY prisma ./prisma/
# Install ALL dependencies (including dev dependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the NestJS application
RUN npm run build
# Remove development dependencies to reduce size
RUN npm prune --production
# ============================================
# STAGE 2: PRODUCTION STAGE
# ============================================
# This stage creates the final production image
FROM node:18-alpine
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Set working directory
WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
adduser -S dyolink -u 1001
# Copy package.json files
COPY package*.json ./
# Copy Prisma schema
COPY prisma ./prisma/
# Install ONLY production dependencies
RUN npm ci --only=production && \
npm cache clean --force
# Generate Prisma client in production
RUN npx prisma generate
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist
# Copy node_modules (already pruned)
COPY --from=builder /app/node_modules ./node_modules
# Create necessary directories with proper permissions
RUN mkdir -p /app/logs && \
chown -R dyolink:nodejs /app
# Set ownership of all files to non-root user
RUN chown -R dyolink:nodejs /app
# Switch to non-root user
USER dyolink
# Expose the application port
EXPOSE 3000
# Health check configuration
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {if(r.statusCode!==200)throw new Error()})" || exit 1
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Use dumb-init to properly handle signals
ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"]
# Start the application
CMD ["node", "dist/main"]

98
backend/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

95
backend/SETUP.md Normal file
View File

@@ -0,0 +1,95 @@
# Dyolink Backend - Development Setup Guide
## 📋 Prerequisites
Before starting, ensure you have the following installed:
- **Node.js** (v18 or higher)
- **PostgreSQL** (v15 or higher) - We use v18, but any v15+ works
- **Git** (for cloning)
- **npm** or **yarn** (npm comes with Node.js)
## 🚀 Initial Setup Steps
1. Clone the Repository
```bash
git clone [your-repository-url]
cd dyolink/backend
2. Install Dependencies
bash
npm install
3. Environment Configuration
Create a .env file in the backend folder:
env
# backend/.env
DATABASE_URL=postgresql://postgres:1234@localhost:5432/dyolink_db
JWT_SECRET=your-super-secret-key-here-change-this
JWT_REFRESH_SECRET=your-super-secret-refresh-key-here-different-from-above
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
PORT=3000
⚠️ Important: Never commit the .env file to git! We have .gitignore set up to prevent this.
4. Database Setup
Option A: Fresh PostgreSQL Installation
If you don't have PostgreSQL installed:
Windows (using Chocolatey):
bash
choco install postgresql
macOS (using Homebrew):
bash
brew install postgresql@15
brew services start postgresql@15
Common Installation Issues & Fixes:
Issue Solution
"Password not set during installation" Edit pg_hba.conf temporarily (see Troubleshooting section)
"Service not starting" Run PowerShell/Terminal as Administrator
"Port 5432 already in use" Stop local PostgreSQL service or change port
Option B: Using Existing PostgreSQL
If you already have PostgreSQL:
bash
# Connect to PostgreSQL
psql -U postgres
# Create the database (if it doesn't exist)
CREATE DATABASE dyolink_db;
\q
5. Database Migrations
Once PostgreSQL is running and you've created the database:
bash
# Generate Prisma client
npx prisma generate
# Run migrations to create tables
npx prisma migrate dev --name init_schema
⚠️ Known Issue: If you get P1001: Can't reach database server, ensure PostgreSQL is running:
bash
# Check PostgreSQL status
# Windows:
Get-Service postgresql-x64-18
# macOS:
brew services list | grep postgres
6. Seed the Database
bash
# Seed with initial data (organization types, plans, permissions, test user)
npx prisma db seed
⚠️ Prisma 7 Note: If seeding fails with PrismaClientInitializationError, we've fixed this by using the driver adapter pattern. The seed file now uses:
typescript
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
7. Verify Setup
bash
# Open Prisma Studio to verify data
npx prisma studio
# This opens http://localhost:5555 - you should see all tables with seeded data
8. Start Development Server
bash
npm run start:dev
You should see:
text
Application is running on: http://localhost:3000
✅ Database connected successfully

View File

@@ -0,0 +1,76 @@
#!/bin/sh
set -e
# ============================================
# DOCKER ENTRYPOINT SCRIPT
# This script runs BEFORE the application starts
# ============================================
# Colors for logging (optional, for better readability)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "${GREEN}========================================${NC}"
echo "${GREEN} Dyolink Backend - Docker Entrypoint ${NC}"
echo "${GREEN}========================================${NC}"
# Check if we're in development or production
if [ "$NODE_ENV" = "production" ]; then
echo "${GREEN}Running in PRODUCTION mode${NC}"
# Run database migrations
echo "${YELLOW}Running database migrations...${NC}"
npx prisma migrate deploy
# Check if migrations were successful
if [ $? -eq 0 ]; then
echo "${GREEN}✓ Database migrations completed successfully${NC}"
else
echo "${RED}✗ Database migrations failed!${NC}"
exit 1
fi
else
echo "${YELLOW}Running in DEVELOPMENT mode${NC}"
# In development, we might want to push schema instead of migrations
echo "${YELLOW}Syncing database schema...${NC}"
npx prisma db push
if [ $? -eq 0 ]; then
echo "${GREEN}✓ Database schema synced successfully${NC}"
else
echo "${RED}✗ Database schema sync failed!${NC}"
exit 1
fi
fi
# Optional: Run seed script if it exists and NODE_ENV is not production
if [ "$NODE_ENV" != "production" ] && [ -f "prisma/seed.js" ]; then
echo "${YELLOW}Running database seed...${NC}"
npx prisma db seed
echo "${GREEN}✓ Database seeded successfully${NC}"
fi
# Verify database connection
echo "${YELLOW}Verifying database connection...${NC}"
npx prisma db execute --file /dev/null --schema prisma/schema.prisma 2>/dev/null
if [ $? -eq 0 ]; then
echo "${GREEN}✓ Database connection verified${NC}"
else
echo "${RED}✗ Cannot connect to database!${NC}"
exit 1
fi
# Print application information
echo "${GREEN}========================================${NC}"
echo "${GREEN}Starting Dyolink Backend Application...${NC}"
echo "${GREEN} • Environment: ${NODE_ENV:-development}${NC}"
echo "${GREEN} • Port: ${PORT:-3000}${NC}"
echo "${GREEN} • Database: ${DATABASE_URL%%@*}@***${NC}"
echo "${GREEN}========================================${NC}"
# Execute the main command (passed as CMD)
exec "$@"

35
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

BIN
backend/output.txt Normal file

Binary file not shown.

114
backend/package.json Normal file
View File

@@ -0,0 +1,114 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed"
},
"prisma": {
"seed": "ts-node prisma/seed.ts"
},
"dependencies": {
"@adminjs/design-system": "^4.1.1",
"@adminjs/express": "^6.1.1",
"@adminjs/nestjs": "^7.0.0",
"@adminjs/prisma": "^5.0.4",
"@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.2",
"adminjs": "^7.8.17",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"express-formidable": "^1.2.0",
"express-session": "^1.19.0",
"helmet": "^8.1.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"pg": "^8.18.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"styled-components": "^6.3.11",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.0",
"@types/express-session": "^1.18.2",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/pg": "^8.16.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/styled-components": "^5.1.36",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^6.19.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,191 @@
-- CreateEnum
CREATE TYPE "LinkStatus" AS ENUM ('PENDING', 'ACTIVE', 'REJECTED', 'BLOCKED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT,
"googleId" TEXT,
"facebookId" TEXT,
"name" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organization_types" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "organization_types_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organizations" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"phone" TEXT,
"address" TEXT,
"typeId" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"planId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organizations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "plans" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"maxUsers" INTEGER NOT NULL,
"price" DOUBLE PRECISION NOT NULL,
"features" JSONB NOT NULL,
CONSTRAINT "plans_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "memberships" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"isOwner" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "memberships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"featureId" TEXT,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "membership_permissions" (
"membershipId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
CONSTRAINT "membership_permissions_pkey" PRIMARY KEY ("membershipId","permissionId")
);
-- CreateTable
CREATE TABLE "features" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"organizationTypeId" TEXT,
CONSTRAINT "features_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "organization_links" (
"id" TEXT NOT NULL,
"organizationAId" TEXT NOT NULL,
"organizationBId" TEXT NOT NULL,
"status" "LinkStatus" NOT NULL DEFAULT 'PENDING',
"sharedDataTypes" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organization_links_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"userAgent" TEXT,
"ipAddress" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_googleId_key" ON "users"("googleId");
-- CreateIndex
CREATE UNIQUE INDEX "users_facebookId_key" ON "users"("facebookId");
-- CreateIndex
CREATE UNIQUE INDEX "organization_types_name_key" ON "organization_types"("name");
-- CreateIndex
CREATE UNIQUE INDEX "organizations_email_key" ON "organizations"("email");
-- CreateIndex
CREATE UNIQUE INDEX "plans_name_key" ON "plans"("name");
-- CreateIndex
CREATE UNIQUE INDEX "memberships_userId_organizationId_key" ON "memberships"("userId", "organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_name_key" ON "permissions"("name");
-- CreateIndex
CREATE UNIQUE INDEX "features_name_key" ON "features"("name");
-- CreateIndex
CREATE UNIQUE INDEX "organization_links_organizationAId_organizationBId_key" ON "organization_links"("organizationAId", "organizationBId");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_refreshToken_key" ON "sessions"("refreshToken");
-- AddForeignKey
ALTER TABLE "organizations" ADD CONSTRAINT "organizations_typeId_fkey" FOREIGN KEY ("typeId") REFERENCES "organization_types"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organizations" ADD CONSTRAINT "organizations_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organizations" ADD CONSTRAINT "organizations_planId_fkey" FOREIGN KEY ("planId") REFERENCES "plans"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "permissions" ADD CONSTRAINT "permissions_featureId_fkey" FOREIGN KEY ("featureId") REFERENCES "features"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "membership_permissions" ADD CONSTRAINT "membership_permissions_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "memberships"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "membership_permissions" ADD CONSTRAINT "membership_permissions_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "permissions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "features" ADD CONSTRAINT "features_organizationTypeId_fkey" FOREIGN KEY ("organizationTypeId") REFERENCES "organization_types"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organization_links" ADD CONSTRAINT "organization_links_organizationAId_fkey" FOREIGN KEY ("organizationAId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organization_links" ADD CONSTRAINT "organization_links_organizationBId_fkey" FOREIGN KEY ("organizationBId") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global() // makes it available everywhere without re-importing
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,22 @@
// backend/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
constructor() {
super();
}
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,172 @@
// backend/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(uuid())
email String @unique
passwordHash String?
googleId String? @unique
facebookId String? @unique
name String
memberships Membership[]
ownedOrganizations Organization[] @relation("OrganizationOwner")
sessions Session[] // 👈 ADD THIS - opposite relation for Session
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model OrganizationType {
id String @id @default(uuid())
name String @unique // "CLINIC" or "LAB"
organizations Organization[]
features Feature[] // 👈 ADD THIS - opposite relation for Feature
@@map("organization_types")
}
model Organization {
id String @id @default(uuid())
name String
email String @unique
phone String?
address String?
typeId String
type OrganizationType @relation(fields: [typeId], references: [id])
ownerId String
owner User @relation("OrganizationOwner", fields: [ownerId], references: [id])
memberships Membership[]
planId String
plan Plan @relation(fields: [planId], references: [id])
sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("organizations")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
maxUsers Int // 1, 5, 10, 15, 999999 for unlimited
price Float
features Json // Store feature flags as JSON
organizations Organization[]
@@map("plans")
}
model Membership {
id String @id @default(uuid())
userId String
organizationId String
isOwner Boolean @default(false)
user User @relation(fields: [userId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id])
permissions MembershipPermission[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, organizationId])
@@map("memberships")
}
model Permission {
id String @id @default(uuid())
name String @unique
description String?
memberships MembershipPermission[]
featureId String?
feature Feature? @relation(fields: [featureId], references: [id])
@@map("permissions")
}
model MembershipPermission {
membershipId String
permissionId String
membership Membership @relation(fields: [membershipId], references: [id])
permission Permission @relation(fields: [permissionId], references: [id])
@@id([membershipId, permissionId])
@@map("membership_permissions")
}
model Feature {
id String @id @default(uuid())
name String @unique
description String?
permissions Permission[]
organizationTypeId String?
organizationType OrganizationType? @relation(fields: [organizationTypeId], references: [id])
@@map("features")
}
model OrganizationLink {
id String @id @default(uuid())
organizationAId String
organizationBId String
status LinkStatus @default(PENDING)
sharedDataTypes Json
organizationA Organization @relation("OrganizationA", fields: [organizationAId], references: [id])
organizationB Organization @relation("OrganizationB", fields: [organizationBId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([organizationAId, organizationBId])
@@map("organization_links")
}
model Session {
id String @id @default(uuid())
userId String
token String @unique
refreshToken String? @unique
expiresAt DateTime
userAgent String?
ipAddress String?
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("sessions")
}
enum LinkStatus {
PENDING
ACTIVE
REJECTED
BLOCKED
}

122
backend/prisma/seed.ts Normal file
View File

@@ -0,0 +1,122 @@
// backend/prisma/seed.ts
import { PrismaClient } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { config } from 'dotenv';
import path from 'path';
// Load environment variables from the correct path
const envPath = path.join(__dirname, '..', '.env');
console.log('Loading .env from:', envPath);
config({ path: envPath });
// Verify DATABASE_URL is loaded
if (!process.env.DATABASE_URL) {
console.error('❌ DATABASE_URL is not set in environment');
console.log('Current directory:', process.cwd());
console.log('.env path:', envPath);
process.exit(1);
}
console.log('✅ DATABASE_URL found:', process.env.DATABASE_URL.substring(0, 30) + '...');
const prisma = new PrismaClient();
async function main() {
console.log('🌱 Starting seeding...');
// Test the connection
await prisma.$connect();
console.log('✅ Database connected successfully');
// Create organization types
const clinicType = await prisma.organizationType.upsert({
where: { name: 'CLINIC' },
update: {},
create: { name: 'CLINIC' },
});
console.log('✅ Created clinic type');
const labType = await prisma.organizationType.upsert({
where: { name: 'LAB' },
update: {},
create: { name: 'LAB' },
});
console.log('✅ Created lab type');
// Create plans
const plans = [
{ name: 'trial', maxUsers: 5, price: 0, features: {} },
{ name: 'Small', maxUsers: 5, price: 79, features: {} },
{ name: 'Medium', maxUsers: 10, price: 129, features: {} },
{ name: 'Large', maxUsers: 15, price: 179, features: {} },
{ name: 'Enterprise', maxUsers: 999999, price: 299, features: {} },
];
for (const plan of plans) {
await prisma.plan.upsert({
where: { name: plan.name },
update: {},
create: plan,
});
}
console.log('✅ Created plans');
// Create features and permissions
const features = [
{
name: 'Patient Management',
permissions: ['VIEW_PATIENTS', 'CREATE_PATIENTS', 'EDIT_PATIENTS', 'DELETE_PATIENTS']
},
{
name: 'Order Management',
permissions: ['VIEW_ORDERS', 'CREATE_ORDERS', 'EDIT_ORDERS', 'DELETE_ORDERS', 'TRACK_ORDERS']
},
{
name: 'Case Management',
permissions: ['VIEW_CASES', 'CREATE_CASES', 'EDIT_CASES', 'DELETE_CASES']
},
{
name: 'Reports',
permissions: ['VIEW_REPORTS', 'EXPORT_REPORTS']
},
{
name: 'Team Management',
permissions: ['INVITE_USERS', 'REMOVE_USERS', 'MANAGE_PERMISSIONS']
},
{
name: 'Billing',
permissions: ['VIEW_INVOICES', 'CREATE_INVOICES', 'MANAGE_PAYMENTS']
}
];
for (const feature of features) {
const createdFeature = await prisma.feature.upsert({
where: { name: feature.name },
update: {},
create: { name: feature.name },
});
for (const permissionName of feature.permissions) {
await prisma.permission.upsert({
where: { name: permissionName },
update: {},
create: {
name: permissionName,
featureId: createdFeature.id,
},
});
}
}
console.log('✅ Created features and permissions');
console.log('🌱 Seeding completed successfully!'); ``
}
main()
.catch((e) => {
console.error('❌ Seeding failed:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,109 @@
// backend/src/admin/admin.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { componentLoader, Components } from './components';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Database, Resource, getModelByName } from '@adminjs/prisma'; // 👈 Add getModelByName
import AdminJS from 'adminjs';
// Register the adapter
AdminJS.registerAdapter({ Database, Resource });
@Module({
imports: [ConfigModule],
})
export class AdminModule {
static async forRoot(): Promise<DynamicModule> {
const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs');
const authenticate = async (email: string, password: string) => {
if (email === 'admin@dyolink.com' && password === 'admin123') {
return { email, role: 'admin' };
}
return null;
};
return {
module: AdminModule,
imports: [
await AdminJSModule.createAdminAsync({
imports: [ConfigModule],
inject: [PrismaService, ConfigService],
useFactory: (prisma: PrismaService, config: ConfigService) => {
return {
adminJsOptions: {
rootPath: '/admin',
resources: [
// ✅ Use getModelByName helper
{
resource: {
model: getModelByName('User'),
client: prisma,
},
options: {
properties: {
passwordHash: { isVisible: false },
},
},
},
{
resource: {
model: getModelByName('Organization'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('OrganizationType'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Plan'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Membership'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Session'),
client: prisma,
},
options: {},
},
],
componentLoader,
dashboard: { component: Components.Dashboard },
branding: {
companyName: 'DyoLink Admin',
logo: false,
softwareBrothers: false,
},
},
auth: {
authenticate,
cookieName: 'dyolink-admin',
cookiePassword: config.get('JWT_SECRET') || 'secret-key-change-this',
},
sessionOptions: {
resave: false,
saveUninitialized: false,
secret: config.get('JWT_SECRET') || 'secret-key-change-this',
},
};
},
}),
],
};
}
}

View File

@@ -0,0 +1,11 @@
// backend/src/admin/components.ts
import { ComponentLoader } from 'adminjs';
const componentLoader = new ComponentLoader();
const Components = {
Dashboard: componentLoader.add('Dashboard', './dashboard'),
// You can add more components here as needed
};
export { componentLoader, Components };

View File

@@ -0,0 +1,32 @@
// backend/src/admin/dashboard-simple.tsx
// @ts-nocheck
import React from 'react';
import { Box, H2, Text, Badge } from '@adminjs/design-system';
const Dashboard = () => {
return (
<Box variant="grey">
<Box variant="white" p="xl">
<H2>Welcome to DyoLink Admin Panel</H2>
<Text>Manage your dental clinics, labs, users, and subscriptions.</Text>
<Box mt="xl" style={{ display: 'flex', gap: '20px' }}>
<Box p="lg" bg="primary20" style={{ flex: 1 }}>
<div style={{ fontSize: '1.5rem' }}>🏥 Clinics</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>12</div>
</Box>
<Box p="lg" bg="secondary20" style={{ flex: 1 }}>
<div style={{ fontSize: '1.5rem' }}>🔬 Labs</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>8</div>
</Box>
<Box p="lg" bg="info20" style={{ flex: 1 }}>
<div style={{ fontSize: '1.5rem' }}>👥 Users</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>45</div>
</Box>
</Box>
</Box>
</Box>
);
};
export default Dashboard;

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -0,0 +1,25 @@
// backend/src/app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getRoot() {
return {
message: 'DyoLink API',
version: '1.0',
endpoints: {
auth: '/api/auth',
docs: '/api/docs',
},
};
}
@Get('hello') // This will be at /api/hello
getHello(): string {
return this.appService.getHello();
}
}

23
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import configurations from './configs/configurations';
import { AuthModule } from './modules/auth/auth.module';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AdminModule } from './admin/admin.module';
import { PrismaModule } from '../prisma/prisma.module'; // ✅
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [configurations],
}),
PrismaModule, // ✅ ADD THIS
AuthModule,
AdminModule.forRoot(),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

View File

@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -0,0 +1,53 @@
// backend/src/config/configuration.ts
export interface Config {
port: number;
database: {
url: string;
};
jwt: {
secret: string;
expiresIn: string;
};
throttle: {
ttl: number;
limit: number;
};
}
export default (): Config => {
// Helper function to get required env var with type safety
const getEnvVar = (key: string): string => {
const value = process.env[key];
if (!value) {
throw new Error(`❌ Environment variable ${key} is required but not set`);
}
return value;
};
// Helper for optional env vars with defaults
const getEnvVarWithDefault = (key: string, defaultValue: string): string => {
return process.env[key] || defaultValue;
};
const getEnvVarAsNumber = (key: string, defaultValue: number): number => {
const value = process.env[key];
if (!value) return defaultValue;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? defaultValue : parsed;
};
return {
port: getEnvVarAsNumber('PORT', 3000),
database: {
url: getEnvVar('DATABASE_URL'),
},
jwt: {
secret: getEnvVar('JWT_SECRET'),
expiresIn: getEnvVarWithDefault('JWT_EXPIRES_IN', '7d'),
},
throttle: {
ttl: getEnvVarAsNumber('THROTTLE_TTL', 60),
limit: getEnvVarAsNumber('THROTTLE_LIMIT', 100),
},
};
};

81
backend/src/main.ts Normal file
View File

@@ -0,0 +1,81 @@
// backend/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import cookieParser from 'cookie-parser'; // 👈 Change this line!
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
// At the VERY TOP of main.ts, before anything else
const originalConsoleLog = console.log;
console.log = (...args) => {
// Check if this is the massive Prisma dump (contains _clientVersion)
if (args.some(arg => arg && typeof arg === 'object' && arg._clientVersion)) {
console.error = originalConsoleLog; // Temporarily restore for this message
originalConsoleLog('🔍🔍🔍 PRISMA CLIENT DUMP DETECTED 🔍🔍🔍');
originalConsoleLog('Stack trace:', new Error().stack);
return; // Don't print the actual object
}
originalConsoleLog.apply(console, args);
};
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Global pipes
app.useGlobalPipes(new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}));
// Cookie parser - this is correct for Express
app.use(cookieParser());
// CORS
app.enableCors({
origin: process.env.FRONTEND_URL || 'http://localhost:3001',
credentials: true,
});
// Global prefix
app.setGlobalPrefix('api');
// Swagger configuration
const swaggerConfig = new DocumentBuilder()
.setTitle('Dyolink API')
.setDescription('Dental Clinic & Lab Communication Hub API')
.setVersion('1.0')
.addTag('auth', 'Authentication endpoints')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
name: 'JWT',
description: 'Enter JWT token',
in: 'header',
},
'JWT-auth',
)
.build();
const document = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, document, {
swaggerOptions: {
persistAuthorization: true,
tagsSorter: 'alpha',
operationsSorter: 'alpha',
},
customSiteTitle: 'Dyolink API Documentation',
});
const port = parseInt(process.env.PORT || '', 10) || 3000;
await app.listen(port);
console.log(`🚀 Application is running on: http://localhost:${port}/api`);
console.log(`📚 Swagger documentation: http://localhost:${port}/api/docs`);
console.log(`📚 AdminJS Panel: http://localhost:${port}/admin`);
}
bootstrap();

View File

@@ -0,0 +1,177 @@
// backend/src/modules/auth/auth.controller.ts
import {
Controller,
Post,
Body,
UseGuards,
Req,
Res,
HttpCode,
HttpStatus,
Get
} from '@nestjs/common';
import type { Response } from 'express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiBody,
ApiUnauthorizedResponse,
ApiBadRequestResponse
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { LocalAuthGuard } from './guards/local-auth.guard';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
// =========================
// LOGIN
// =========================
@Post('login')
@HttpCode(HttpStatus.OK)
@UseGuards(LocalAuthGuard)
@ApiOperation({ summary: 'Login with email and password' })
@ApiBody({ type: LoginDto })
@ApiResponse({ status: 200, description: 'Login successful' })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
@ApiBadRequestResponse({ description: 'Invalid input data' })
async login(
@Body() loginDto: LoginDto,
@Req() req,
@Res({ passthrough: true }) res: Response
) {
console.log('Login endpoint hit');
const result = await this.authService.login(loginDto, req.user);
// ✅ SET COOKIES HERE
this.setAuthCookies(res, result.data.accessToken, result.data.refreshToken);
return {
success: true,
data: {
user: result.data.user,
organizations: result.data.organizations,
},
};
}
// =========================
// REGISTER
// =========================
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiBody({ type: RegisterDto })
@ApiResponse({ status: 201, description: 'User registered successfully' })
@ApiBadRequestResponse({ description: 'Invalid input data' })
async register(
@Body() registerDto: RegisterDto,
@Res({ passthrough: true }) res: Response
) {
console.log('Register endpoint hit');
const result = await this.authService.register(registerDto);
// ✅ SET COOKIES HERE
this.setAuthCookies(res, result.data.accessToken, result.data.refreshToken);
return {
success: true,
data: {
user: result.data.user,
organizations: result.data.organizations,
},
};
}
// =========================
// SELECT ORGANIZATION
// =========================
@Post('select-organization')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
async selectOrganization(
@Req() req,
@Body('organizationId') organizationId: string,
@Res({ passthrough: true }) res: Response
) {
const result = await this.authService.selectOrganization(
req.user.id,
organizationId
);
// 🔥 Replace access token with org-scoped token
this.setAccessToken(res, result.data.accessToken);
return {
success: true,
data: {
organization: result.data.organization,
},
};
}
// =========================
// PROFILE
// =========================
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get user profile' })
@ApiResponse({ status: 200, description: 'Profile retrieved successfully' })
@ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' })
async getProfile(@Req() req) {
console.log('Profile endpoint hit');
console.log('USER FROM JWT:', req.user);
return this.authService.getProfile(req.user.id);
}
// =========================
// TEST
// =========================
@Get('test')
@ApiOperation({ summary: 'Test endpoint' })
test() {
return { message: 'Auth controller is working!' };
}
// =========================
// 🔥 COOKIE HELPERS
// =========================
private setAuthCookies(
res: Response,
accessToken: string,
refreshToken: string
) {
this.setAccessToken(res, accessToken);
this.setRefreshToken(res, refreshToken);
}
private setAccessToken(res: Response, token: string) {
res.cookie('accessToken', token, {
httpOnly: true,
secure: false, // ⚠️ true in production (HTTPS)
sameSite: 'lax',
path: '/',
});
}
private setRefreshToken(res: Response, token: string) {
res.cookie('refreshToken', token, {
httpOnly: true,
secure: false,
sameSite: 'lax',
path: '/',
});
}
}

View File

@@ -0,0 +1,33 @@
// backend/src/modules/auth/auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PrismaService } from '../../../prisma/prisma.service';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get('jwt.secret'),
signOptions: { expiresIn: configService.get('jwt.expiresIn') },
}),
inject: [ConfigService],
}),
],
controllers: [AuthController], // THIS MUST BE HERE
providers: [
AuthService,
PrismaService,
LocalStrategy,
JwtStrategy,
],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,668 @@
// backend/src/modules/auth/auth.service.ts
import {
Injectable,
UnauthorizedException,
BadRequestException,
ConflictException,
InternalServerErrorException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../../../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [
'VIEW_PATIENTS',
'CREATE_PATIENTS',
'EDIT_PATIENTS',
'DELETE_PATIENTS',
'VIEW_ORDERS',
'CREATE_ORDERS',
'EDIT_ORDERS',
'DELETE_ORDERS',
'TRACK_ORDERS',
'VIEW_CASES',
'CREATE_CASES',
'EDIT_CASES',
'DELETE_CASES',
'VIEW_REPORTS',
'EXPORT_REPORTS',
'INVITE_USERS',
'REMOVE_USERS',
'MANAGE_PERMISSIONS',
'VIEW_INVOICES',
'CREATE_INVOICES',
'MANAGE_PAYMENTS',
];
@Injectable()
export class AuthService {
constructor(
private prisma: PrismaService,
private jwtService: JwtService,
private configService: ConfigService,
) { }
/**
* Validate user credentials (used by LocalStrategy)
* @param email - User's email
* @param password - User's password
* @returns User object without passwordHash or null if invalid
*/
async validateUser(email: string, password: string): Promise<any> {
try {
const user = await this.prisma.user.findUnique({
where: { email },
include: {
memberships: {
include: {
organization: {
include: {
type: true, // Include organization type (CLINIC/LAB)
}
},
permissions: {
include: {
permission: true, // Include permission details
},
},
},
},
},
});
if (!user) {
return null;
}
// Check if user has a password (might be OAuth only, but we're not using OAuth)
if (!user.passwordHash) {
return null;
}
const isPasswordValid = await bcrypt.compare(password, user.passwordHash);
if (!isPasswordValid) {
return null;
}
// Remove sensitive data
const { passwordHash, ...result } = user;
return result;
} catch (error) {
throw new InternalServerErrorException('Error validating user');
}
}
/**
* Login user and generate tokens
* @param loginDto - Login credentials (email, password)
* @param user - Validated user object from LocalStrategy
* @returns Access token, refresh token, user info, and organizations
*/
async login(loginDto: LoginDto, user: any) {
try {
// Generate access token (short-lived)
const accessPayload: JwtPayload = {
sub: user.id,
email: user.email,
type: 'access'
};
// Generate refresh token (long-lived)
const refreshPayload: JwtPayload = {
sub: user.id,
email: user.email,
type: 'refresh'
};
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'),
}),
]);
// Store session in database
await this.prisma.session.create({
data: {
userId: user.id,
token: accessToken,
refreshToken: refreshToken,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
},
});
// Transform memberships to include organization info and permissions
const organizations = user.memberships?.map(membership => ({
id: membership.organization.id,
name: membership.organization.name,
type: membership.organization.type.name, // 'CLINIC' or 'LAB'
isOwner: membership.isOwner,
permissions: membership.isOwner
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
})) || [];
return {
success: true,
data: {
accessToken,
refreshToken,
user: {
id: user.id,
email: user.email,
name: user.name,
},
organizations,
},
};
} catch (error) {
//throw new InternalServerErrorException('Login failed');
console.error('🔥 LOGIN ERROR FULL:', error);
throw error;
}
}
/**
* Register a new user
* @param registerDto - Registration data (email, password, name)
* @returns Created user info without password
*/
async register(registerDto: RegisterDto) {
const { email, password, name, organizationName, organizationType } = registerDto;
// 1. Check existing user
const existingUser = await this.prisma.user.findUnique({
where: { email },
});
if (existingUser) {
throw new ConflictException('User already exists');
}
// 2. Hash password
const hashedPassword = await bcrypt.hash(password, 10);
// 3. Transaction (IMPORTANT)
const result = await this.prisma.$transaction(async (tx) => {
// Create user
const user = await tx.user.create({
data: {
email,
passwordHash: hashedPassword,
name,
},
});
// Create organization
const organization = await tx.organization.create({
data: {
name: registerDto.organizationName,
// REQUIRED FIELDS 👇
email: registerDto.email, // or separate org email if you have one
owner: {
connect: { id: user.id },
},
plan: {
connect: { name: 'trial' }, // make sure this exists in DB
},
type: {
connect: {
name: registerDto.organizationType, // 'CLINIC' | 'LAB'
},
},
},
});
// Create membership (owner)
await tx.membership.create({
data: {
userId: user.id,
organizationId: organization.id,
isOwner: true,
},
});
return { user, organization };
});
// 4. Generate tokens (reuse login logic)
const validatedUser = await this.validateUser(email, password);
if (!validatedUser) {
throw new UnauthorizedException('Auto-login failed');
}
return this.login({ email, password } as any, validatedUser);
}
/**
* Get user profile with all memberships and permissions
* @param userId - User ID from JWT token
* @returns User profile with organizations and permissions
*/
async getProfile(userId: string) {
try {
const user = await this.prisma.user.findUnique({
where: { id: userId },
include: {
memberships: {
include: {
organization: {
include: {
type: true,
},
},
permissions: {
include: {
permission: true,
},
},
},
},
},
});
if (!user) {
throw new UnauthorizedException('User not found');
}
const { passwordHash, ...result } = user;
// Transform memberships for frontend consumption
const organizations = user.memberships?.map(membership => ({
id: membership.organization.id,
name: membership.organization.name,
type: membership.organization.type.name,
isOwner: membership.isOwner,
permissions: membership.permissions?.map(p => p.permission.name) || [],
})) || [];
return {
success: true,
data: {
...result,
organizations,
},
};
} catch (error) {
throw new InternalServerErrorException('Failed to get profile');
}
}
/**
* Logout user by invalidating their session
* @param token - Access token to invalidate
* @returns Success message
*/
async logout(token: string) {
try {
await this.prisma.session.deleteMany({
where: { token },
});
return {
success: true,
message: 'Logged out successfully',
};
} catch (error) {
throw new InternalServerErrorException('Logout failed');
}
}
/**
* Refresh access token using refresh token
* @param refreshToken - Valid refresh token
* @returns New access token
*/
async refreshToken(refreshToken: string) {
try {
// Verify the refresh token
const payload = await this.jwtService.verifyAsync(refreshToken, {
secret: this.configService.get('jwt.refreshSecret'),
});
// Ensure this is a refresh token
if (payload.type !== 'refresh') {
throw new UnauthorizedException('Invalid token type');
}
// Find session with this refresh token
const session = await this.prisma.session.findFirst({
where: {
refreshToken,
expiresAt: { gt: new Date() }
},
include: {
user: {
include: {
memberships: {
include: {
organization: {
include: {
type: true,
},
},
permissions: {
include: {
permission: true,
},
},
},
},
},
},
},
});
if (!session) {
throw new UnauthorizedException('Invalid refresh token');
}
// Generate new access token
const newAccessPayload: JwtPayload = {
sub: session.user.id,
email: session.user.email,
type: 'access',
};
const newAccessToken = await this.jwtService.signAsync(newAccessPayload, {
secret: this.configService.get('jwt.secret'),
expiresIn: this.configService.get('jwt.expiresIn'),
});
// Update session with new access token
await this.prisma.session.update({
where: { id: session.id },
data: {
token: newAccessToken,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
// Transform memberships for response
const organizations = session.user.memberships?.map(membership => ({
id: membership.organization.id,
name: membership.organization.name,
type: membership.organization.type.name,
isOwner: membership.isOwner,
permissions: membership.permissions?.map(p => p.permission.name) || [],
})) || [];
return {
success: true,
data: {
accessToken: newAccessToken,
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
},
organizations,
},
};
} catch (error) {
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
throw new UnauthorizedException('Invalid or expired refresh token');
}
throw new UnauthorizedException('Refresh token failed');
}
}
/**
* Change user password
* @param userId - User ID
* @param oldPassword - Current password
* @param newPassword - New password
* @returns Success message
*/
async changePassword(userId: string, oldPassword: string, newPassword: string) {
try {
const user = await this.prisma.user.findUnique({
where: { id: userId },
});
if (!user || !user.passwordHash) {
throw new BadRequestException('User not found or invalid password method');
}
// Verify old password
const isPasswordValid = await bcrypt.compare(oldPassword, user.passwordHash);
if (!isPasswordValid) {
throw new UnauthorizedException('Current password is incorrect');
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await this.prisma.user.update({
where: { id: userId },
data: { passwordHash: hashedPassword },
});
// Invalidate all sessions for this user (force re-login)
await this.prisma.session.deleteMany({
where: { userId },
});
return {
success: true,
message: 'Password changed successfully. Please login again.',
};
} catch (error) {
if (error instanceof UnauthorizedException || error instanceof BadRequestException) {
throw error;
}
throw new InternalServerErrorException('Failed to change password');
}
}
/**
* Get all active sessions for a user
* @param userId - User ID
* @returns List of active sessions
*/
async getUserSessions(userId: string) {
try {
const sessions = await this.prisma.session.findMany({
where: {
userId,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
});
return {
success: true,
data: sessions,
};
} catch (error) {
throw new InternalServerErrorException('Failed to get sessions');
}
}
/**
* Revoke a specific session
* @param userId - User ID
* @param sessionId - Session ID to revoke
* @returns Success message
*/
async revokeSession(userId: string, sessionId: string) {
try {
await this.prisma.session.delete({
where: {
id: sessionId,
userId, // Ensure session belongs to user
},
});
return {
success: true,
message: 'Session revoked successfully',
};
} catch (error) {
throw new InternalServerErrorException('Failed to revoke session');
}
}
/**
* Revoke all sessions for a user (except current)
* @param userId - User ID
* @param currentToken - Current access token to keep
* @returns Success message
*/
async revokeAllSessions(userId: string, currentToken: string) {
try {
await this.prisma.session.deleteMany({
where: {
userId,
token: { not: currentToken }, // Keep current session
},
});
return {
success: true,
message: 'All other sessions revoked successfully',
};
} catch (error) {
throw new InternalServerErrorException('Failed to revoke sessions');
}
}
/**
* Validate token and return user
* @param token - JWT token
* @returns User info if token is valid
*/
async validateToken(token: string) {
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get('jwt.secret'),
});
if (payload.type !== 'access') {
throw new UnauthorizedException('Invalid token type');
}
const session = await this.prisma.session.findFirst({
where: {
token,
expiresAt: { gt: new Date() }
},
include: {
user: {
include: {
memberships: {
include: {
organization: {
include: {
type: true,
},
},
permissions: {
include: {
permission: true,
},
},
},
},
},
},
},
});
if (!session) {
throw new UnauthorizedException('Session not found or expired');
}
const { passwordHash, ...user } = session.user;
const organizations = session.user.memberships?.map(membership => ({
id: membership.organization.id,
name: membership.organization.name,
type: membership.organization.type.name,
isOwner: membership.isOwner,
permissions: membership.permissions?.map(p => p.permission.name) || [],
})) || [];
return {
success: true,
data: {
user,
organizations,
},
};
} catch (error) {
throw new UnauthorizedException('Invalid token');
}
}
async selectOrganization(userId: string, organizationId: string) {
// 1. Verify membership
const membership = await this.prisma.membership.findFirst({
where: {
userId,
organizationId,
},
include: {
organization: {
include: {
type: true,
plan: true,
},
},
permissions: {
include: {
permission: true,
},
},
},
});
if (!membership) {
throw new UnauthorizedException('Access denied to this organization');
}
// 2. Build payload WITH org context
const payload = {
sub: userId,
email: membership.organization.email,
organizationId: membership.organizationId,
type: 'access',
};
// 3. Generate new token
const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get('JWT_SECRET'),
expiresIn: this.configService.get('JWT_EXPIRES_IN'),
});
// 4. Format permissions
const permissions = membership.permissions.map(p => p.permission.name);
return {
success: true,
data: {
accessToken,
organization: {
id: membership.organization.id,
name: membership.organization.name,
type: membership.organization.type.name,
},
permissions,
},
};
}
}

View File

@@ -0,0 +1,23 @@
// backend/src/modules/auth/dto/login.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginDto {
@ApiProperty({
description: 'User email address',
example: 'user@example.com',
required: true,
})
@IsEmail({}, { message: 'Please provide a valid email address' })
email: string;
@ApiProperty({
description: 'User password (min 6 characters)',
example: 'password123',
required: true,
minLength: 6,
})
@IsString()
@MinLength(6, { message: 'Password must be at least 6 characters long' })
password: string;
}

View File

@@ -0,0 +1,6 @@
export class OAuthUserDto {
email: string;
name: string;
googleId?: string;
facebookId?: string;
}

View File

@@ -0,0 +1,19 @@
import { IsEmail, IsString, MinLength, IsEnum } from 'class-validator';
export class RegisterDto {
@IsEmail()
email: string;
@IsString()
@MinLength(8)
password: string;
@IsString()
name: string;
@IsString()
organizationName: string;
@IsEnum(['CLINIC', 'LAB'])
organizationType: 'CLINIC' | 'LAB';
}

View File

@@ -0,0 +1,10 @@
// backend/src/modules/auth/guards/jwt-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
/**
* JwtAuthGuard triggers the JWT passport strategy
* It validates the JWT token from the Authorization header
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

View File

@@ -0,0 +1,10 @@
// backend/src/modules/auth/guards/local-auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
/**
* LocalAuthGuard triggers the local passport strategy
* It validates user credentials (email/password) before login
*/
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}

View File

@@ -0,0 +1,7 @@
// backend/src/modules/auth/interfaces/jwt-payload.interface.ts
export interface JwtPayload {
sub: string; // user id
email: string;
type?: 'access' | 'refresh';
}

View File

@@ -0,0 +1,36 @@
// backend/src/modules/auth/strategies/jwt.strategy.ts
import { Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../../../../prisma/prisma.service';
import { Request } from 'express';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private configService: ConfigService,
private prisma: PrismaService,
) {
super({
jwtFromRequest: (req: Request) => {
return req?.cookies?.accessToken; // ✅ READ FROM COOKIE
},
ignoreExpiration: false,
secretOrKey: configService.get('JWT_SECRET'),
});
}
async validate(payload: any) {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
if (!user) {
throw new UnauthorizedException();
}
const { passwordHash, ...result } = user;
return result;
}
}

View File

@@ -0,0 +1,20 @@
// backend/src/modules/auth/strategies/local.strategy.ts
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({ usernameField: 'email' }); // Use 'email' instead of 'username'
}
async validate(email: string, password: string): Promise<any> {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
return user;
}
}

View File

@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}