commit 26bd35ae3c3d5cd72688c2a09e1b5e875def1ea4 Author: Admin Date: Thu Apr 23 15:33:11 2026 +0330 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8b9343e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.gitignore +README.md +.env +.env.local +.env.production +node_modules +npm-debug.log +.git +.github +.vscode +.idea +*.md +.gitignore +.dockerignore +Dockerfile +docker-compose*.yml +.backup +logs +*.log +.git \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fb0c99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,125 @@ +# Save as: .gitignore (in your main project folder) + +# === Dependencies === +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + +# === Build outputs === +dist/ +build/ +.next/ +out/ +.nuxt/ +.cache/ + +# === Environment files === +.env +.env.local +.env.*.local +*.env + +# === IDE and Editor === +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# === Docker === +docker-compose.override.yml +*.log +docker-data/ +postgres-data/ +redis-data/ +*.pid + +# === Logs === +logs/ +*.log +npm-debug.log* + +# === Runtime data === +pids/ +*.pid +*.seed +*.pid.lock + +# === Coverage directory === +coverage/ +.nyc_output/ + +# === Temporary files === +tmp/ +temp/ +*.tmp +*.temp + +# === OS files === +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +Desktop.ini + +# === Secrets and keys === +*.pem +*.key +*.crt +.secrets/ +secrets/ + +# === Database files === +*.sqlite +*.db +*.sqlite3 + +# === Gitea specific === +.gitea/ + +# Prisma generated files +prisma/*.db +prisma/*.db-journal + +# AdminJS generated files (CRITICAL!) +**/adminjs/** +**/bundle.js +**/entry.js +**.adminjs/ + +# Build outputs +**/dist/ +**/build/ +**/.next/ +**/out/ + +# Test outputs +**/coverage/ +**/.nyc_output/ + +# Logs +**/*.log +**/npm-debug.log* + +# Environment +**/.env +**/.env.* +!**/.env.example + +# IDE (personal) +.vscode/ +.idea/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..ffbd395 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +.git +.gitignore +.env +.env.* +npm-debug.log +README.md +.DS_Store +coverage +*.log +test +*.spec.ts \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..b6276ca --- /dev/null +++ b/backend/.env.example @@ -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 \ No newline at end of file diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..4b56acf --- /dev/null +++ b/backend/.gitignore @@ -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 diff --git a/backend/.prettierrc b/backend/.prettierrc new file mode 100644 index 0000000..a20502b --- /dev/null +++ b/backend/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "trailingComma": "all" +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..e894c77 --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..8f0f65f --- /dev/null +++ b/backend/README.md @@ -0,0 +1,98 @@ +

+ Nest Logo +

+ +[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 +[circleci-url]: https://circleci.com/gh/nestjs/nest + +

A progressive Node.js framework for building efficient and scalable server-side applications.

+

+NPM Version +Package License +NPM Downloads +CircleCI +Discord +Backers on Open Collective +Sponsors on Open Collective + Donate us + Support us + Follow us on Twitter +

+ + +## 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). diff --git a/backend/SETUP.md b/backend/SETUP.md new file mode 100644 index 0000000..566e80f --- /dev/null +++ b/backend/SETUP.md @@ -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 \ No newline at end of file diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..d15cbd3 --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -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 "$@" \ No newline at end of file diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs new file mode 100644 index 0000000..4e9f827 --- /dev/null +++ b/backend/eslint.config.mjs @@ -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" }], + }, + }, +); diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/backend/output.txt b/backend/output.txt new file mode 100644 index 0000000..2963168 Binary files /dev/null and b/backend/output.txt differ diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..6f8c8f0 --- /dev/null +++ b/backend/package.json @@ -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" + } +} diff --git a/backend/prisma/migrations/20260216154742_init_schema/migration.sql b/backend/prisma/migrations/20260216154742_init_schema/migration.sql new file mode 100644 index 0000000..f972486 --- /dev/null +++ b/backend/prisma/migrations/20260216154742_init_schema/migration.sql @@ -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; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/backend/prisma/migrations/migration_lock.toml @@ -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" diff --git a/backend/prisma/prisma.module.ts b/backend/prisma/prisma.module.ts new file mode 100644 index 0000000..e8a8f38 --- /dev/null +++ b/backend/prisma/prisma.module.ts @@ -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 {} \ No newline at end of file diff --git a/backend/prisma/prisma.service.ts b/backend/prisma/prisma.service.ts new file mode 100644 index 0000000..84c6f0f --- /dev/null +++ b/backend/prisma/prisma.service.ts @@ -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(); + } +} \ No newline at end of file diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..b08fc49 --- /dev/null +++ b/backend/prisma/schema.prisma @@ -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 +} diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..73d51d8 --- /dev/null +++ b/backend/prisma/seed.ts @@ -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(); + }); diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts new file mode 100644 index 0000000..bfe4a0f --- /dev/null +++ b/backend/src/admin/admin.module.ts @@ -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 { + 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', + }, + }; + }, + }), + ], + }; + } +} \ No newline at end of file diff --git a/backend/src/admin/components.ts b/backend/src/admin/components.ts new file mode 100644 index 0000000..069a1d1 --- /dev/null +++ b/backend/src/admin/components.ts @@ -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 }; \ No newline at end of file diff --git a/backend/src/admin/dashboard.tsx b/backend/src/admin/dashboard.tsx new file mode 100644 index 0000000..6e5b241 --- /dev/null +++ b/backend/src/admin/dashboard.tsx @@ -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 ( + + +

Welcome to DyoLink Admin Panel

+ Manage your dental clinics, labs, users, and subscriptions. + + + +
πŸ₯ Clinics
+
12
+
+ +
πŸ”¬ Labs
+
8
+
+ +
πŸ‘₯ Users
+
45
+
+
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts new file mode 100644 index 0000000..d22f389 --- /dev/null +++ b/backend/src/app.controller.spec.ts @@ -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); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 0000000..969a91f --- /dev/null +++ b/backend/src/app.controller.ts @@ -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(); + } +} \ No newline at end of file diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..adaca56 --- /dev/null +++ b/backend/src/app.module.ts @@ -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 {} \ No newline at end of file diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts new file mode 100644 index 0000000..927d7cc --- /dev/null +++ b/backend/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/backend/src/configs/configurations.ts b/backend/src/configs/configurations.ts new file mode 100644 index 0000000..2e36ccc --- /dev/null +++ b/backend/src/configs/configurations.ts @@ -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), + }, + }; +}; \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..a7053f1 --- /dev/null +++ b/backend/src/main.ts @@ -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(); \ No newline at end of file diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..07f85de --- /dev/null +++ b/backend/src/modules/auth/auth.controller.ts @@ -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: '/', + }); + } +} \ No newline at end of file diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..6dd2dd8 --- /dev/null +++ b/backend/src/modules/auth/auth.module.ts @@ -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 {} \ No newline at end of file diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..9b23699 --- /dev/null +++ b/backend/src/modules/auth/auth.service.ts @@ -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 { + 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, + }, + }; + } +} \ No newline at end of file diff --git a/backend/src/modules/auth/dto/login.dto.ts b/backend/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..f098bbe --- /dev/null +++ b/backend/src/modules/auth/dto/login.dto.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/modules/auth/dto/oauth.dto.ts b/backend/src/modules/auth/dto/oauth.dto.ts new file mode 100644 index 0000000..06b53ed --- /dev/null +++ b/backend/src/modules/auth/dto/oauth.dto.ts @@ -0,0 +1,6 @@ +export class OAuthUserDto { + email: string; + name: string; + googleId?: string; + facebookId?: string; +} \ No newline at end of file diff --git a/backend/src/modules/auth/dto/register.dto.ts b/backend/src/modules/auth/dto/register.dto.ts new file mode 100644 index 0000000..ed11b46 --- /dev/null +++ b/backend/src/modules/auth/dto/register.dto.ts @@ -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'; +} \ No newline at end of file diff --git a/backend/src/modules/auth/guards/jwt-auth.guard.ts b/backend/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..4855d27 --- /dev/null +++ b/backend/src/modules/auth/guards/jwt-auth.guard.ts @@ -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') {} \ No newline at end of file diff --git a/backend/src/modules/auth/guards/local-auth.guard.ts b/backend/src/modules/auth/guards/local-auth.guard.ts new file mode 100644 index 0000000..9ac0318 --- /dev/null +++ b/backend/src/modules/auth/guards/local-auth.guard.ts @@ -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') {} \ No newline at end of file diff --git a/backend/src/modules/auth/interfaces/jwt-payload.interface.ts b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts new file mode 100644 index 0000000..ab2f27c --- /dev/null +++ b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts @@ -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'; +} + diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..d8d725d --- /dev/null +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -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; + } +} \ No newline at end of file diff --git a/backend/src/modules/auth/strategies/local.strategy.ts b/backend/src/modules/auth/strategies/local.strategy.ts new file mode 100644 index 0000000..fcec4b5 --- /dev/null +++ b/backend/src/modules/auth/strategies/local.strategy.ts @@ -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 { + const user = await this.authService.validateUser(email, password); + if (!user) { + throw new UnauthorizedException('Invalid credentials'); + } + return user; + } +} \ No newline at end of file diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts new file mode 100644 index 0000000..36852c5 --- /dev/null +++ b/backend/test/app.e2e-spec.ts @@ -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; + + 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!'); + }); +}); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/backend/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..aba29b0 --- /dev/null +++ b/backend/tsconfig.json @@ -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 + } +} diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1bd253e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,17 @@ +node_modules +.next +out +.git +.gitignore +.env +.env.* +npm-debug.log +README.md +.DS_Store +coverage +*.log +test +*.test.ts +*.test.tsx +*.spec.ts +*.spec.tsx \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..056cdb2 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,45 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# Prisma +prisma/*.db +prisma/*.db-journal \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e785209 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,72 @@ +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci + +# Copy source code +COPY . . + +# Set build-time environment variables +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + +# Build Next.js application +RUN npm run build + +# Production stage +FROM node:18-alpine + +WORKDIR /app + +# Install dumb-init for proper signal handling +RUN apk add --no-cache dumb-init + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S dyolink -u 1001 + +# Copy package files +COPY package*.json ./ + +# Install production dependencies only +RUN npm ci --only=production && \ + npm cache clean --force + +# Copy built application +COPY --from=builder /app/.next ./.next +COPY --from=builder /app/public ./public +COPY --from=builder /app/next.config.js ./next.config.js +COPY --from=builder /app/package.json ./package.json + +# Create logs directory +RUN mkdir -p /app/logs && \ + chown -R dyolink:nodejs /app + +# Set ownership +RUN chown -R dyolink:nodejs /app + +# Switch to non-root user +USER dyolink + +# Health check +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3000', (r) => {if(r.statusCode!==200)throw new Error()})" || exit 1 + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +ENV NODE_ENV=production + +# Copy entrypoint script +COPY docker-entrypoint.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +# Use dumb-init for signal handling +ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"] + +CMD ["npm", "start"] \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh new file mode 100644 index 0000000..363d4ba --- /dev/null +++ b/frontend/docker-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +set -e + +# frontend/docker-entrypoint.sh + +echo "==========================================" +echo " Dyolink Frontend - Docker Entrypoint" +echo "==========================================" + +# Check if we're in production +if [ "$NODE_ENV" = "production" ]; then + echo "Running in PRODUCTION mode" + + # Verify the backend is reachable (optional) + if [ -n "$NEXT_PUBLIC_API_URL" ]; then + echo "API URL configured: $NEXT_PUBLIC_API_URL" + fi +else + echo "Running in DEVELOPMENT mode" +fi + +echo "Starting Next.js application..." +exec "$@" \ No newline at end of file diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/frontend/next.config.ts b/frontend/next.config.ts new file mode 100644 index 0000000..8b9ab96 --- /dev/null +++ b/frontend/next.config.ts @@ -0,0 +1,33 @@ +import type { NextConfig } from "next"; + +// frontend/next.config.js +/** @type {import('next').NextConfig} */ +const nextConfig = { + // Enable React strict mode + reactStrictMode: true, + + // Disable x-powered-by header for security + poweredByHeader: false, + + // Configure image domains if needed + images: { + domains: process.env.NODE_ENV === 'production' + ? ['yourdomain.com'] + : ['localhost'], + }, + + // Environment variables that will be available at build time + env: { + NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, + }, + + // Output configuration + output: 'standalone', // Reduces Docker image size + + // Compress with gzip + compress: true, +} + +module.exports = nextConfig diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f07681e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001", + "lint": "next lint" + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@tanstack/react-query": "^5.90.21", + "axios": "^1.13.6", + "js-cookie": "^3.0.5", + "lucide-react": "^0.577.0", + "next": "16.1.6", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-hook-form": "^7.71.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "babel-plugin-react-compiler": "1.0.0", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/frontend/public/file.svg b/frontend/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/frontend/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/globe.svg b/frontend/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/frontend/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/next.svg b/frontend/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/frontend/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vercel.svg b/frontend/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/frontend/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/window.svg b/frontend/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/frontend/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx new file mode 100644 index 0000000..c3fd3c1 --- /dev/null +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -0,0 +1,202 @@ +// src/app/(dashboard)/billing/page.tsx +'use client'; +import { useState } from 'react'; +import { Search, Filter, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; +// Mock data matching your design +const invoices = [ + { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, + { id: '#123457', patient: 'Neda Akbari', date: '01/10/2026', service: 'Filling', amount: 700, paid: 400, status: 'overdue' }, + { id: '#123458', patient: 'Nima Haghi', date: '09/12/2026', service: 'Extraction', amount: 450, paid: 450, status: 'paid' }, +]; +const statusColors = { + paid: 'success', + unpaid: 'warning', + overdue: 'danger', +} as const; +type StatCardColor = 'blue' | 'yellow' | 'green' | 'red'; + +interface StatCardProps { + title: string; + count: number; + amount: number; + color: StatCardColor; +} +export default function BillingPage() { + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const stats = { + total: { count: 235, amount: 80900 }, + unpaid: { count: 30, amount: 2800 }, + paid: { count: 190, amount: 80900 }, + overdue: { count: 235, amount: 80900 }, + }; + return ( +
+ {/* Header */} +
+

Billing

+ +
+ {/* Stats Cards - Matching your design */} +
+ + + + +
+ {/* Filters */} +
+
+
+ setSearch(e.target.value)} + icon={} + /> +
+
+ {['all', 'paid', 'unpaid', 'overdue'].map((status) => ( + + ))} +
+
+
+ {/* Invoices Table - Matching your design */} +
+ + + + + + + + + + + + + + + {invoices.map((invoice) => ( + + + + + + + + + + + ))} + +
+ Invoice ID + + Patient name + + Date + + Service + + Total amount + + Paid + + Status + + Action +
+ {invoice.id} + + {invoice.patient} + + {invoice.date} + + {invoice.service} + + ${invoice.amount} + + ${invoice.paid} + + + {invoice.status} + + + +
+ {/* Pagination - Matching your design */} +
+ +
+ Page 1 of 10 +
+ +
+
+
+ ); +} +function StatCard({ title, count, amount, color }: StatCardProps) { + const colors: Record = { + blue: 'bg-blue-50 text-blue-700 border-blue-200', + yellow: 'bg-yellow-50 text-yellow-700 border-yellow-200', + green: 'bg-green-50 text-green-700 border-green-200', + red: 'bg-red-50 text-red-700 border-red-200', + }; + + return ( +
+

{title}

+

{count}

+

+ ${amount.toLocaleString()} +

+
+ ); +} diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..446fdc3 --- /dev/null +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/hooks/useAuth'; +import Sidebar from '@/components/ui/Sidebar'; +import { LogOut } from 'lucide-react'; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const { user, currentOrganization, isAuthReady, logout } = useAuth(); + const router = useRouter(); + + console.log('LAYOUT STATE:', { + user, + currentOrganization, + isAuthReady + }); + + // βœ… AUTH GUARD (runs once per navigation group) + useEffect(() => { + if (!isAuthReady) return; + + if (!user) { + router.replace('/login'); + return; + } + + if (!currentOrganization) { + router.replace('/select-organization'); + return; + } + }, [isAuthReady, user, currentOrganization, router]); + + // βœ… LOADING ONLY FOR INITIAL LOAD + if (!isAuthReady) { + return ( +
+ Loading app... +
+ ); + } + + if (!user || !currentOrganization) { + return ( +
+ Loading workspace... +
+ ); + } + + return ( +
+ + +
+
+

{currentOrganization.name}

+ +
+ {user.name} + +
+
+ +
+ {children} {/* πŸ”₯ THIS CHANGES */} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/today/page.tsx b/frontend/src/app/(dashboard)/today/page.tsx new file mode 100644 index 0000000..704bb9f --- /dev/null +++ b/frontend/src/app/(dashboard)/today/page.tsx @@ -0,0 +1,36 @@ +export default function TodayPage() { + return ( +
+

+ Welcome back Babak !! +

+ +
+ + + + + + +
+
+ ); +} + +function Card({ + title, + value, + sub, +}: { + title: string; + value: string; + sub?: string; +}) { + return ( +
+

{title}

+

{value}

+ {sub &&

{sub}

} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx new file mode 100644 index 0000000..7a29d2f --- /dev/null +++ b/frontend/src/app/(public)/login/page.tsx @@ -0,0 +1,243 @@ +// src/app/login/page.tsx +// 'use client'; +// import { useState } from 'react'; +// import { useForm } from 'react-hook-form'; +// import { zodResolver } from '@hookform/resolvers/zod'; +// import * as z from 'zod'; +// import Link from 'next/link'; +// import { Mail, Lock } from 'lucide-react'; +// import { useAuth } from '@/lib/hooks/useAuth'; +// import { Button } from '@/components/ui/Button'; +// import { Input } from '@/components/ui/Input'; +// const loginSchema = z.object({ +// email: z.string().email('Please enter a valid email address'), +// password: z.string().min(1, 'Password is required'), +// }); +// type LoginForm = z.infer; +// export default function LoginPage() { +// const { login, isLoading } = useAuth(); +// const [error, setError] = useState(null); +// const { +// register, +// handleSubmit, +// formState: { errors }, +// } = useForm({ +// resolver: zodResolver(loginSchema), +// }); +// const onSubmit = async (data: LoginForm) => { +// try { +// setError(null); +// await login(data.email, data.password); +// } catch (err: any) { +// setError(err.message || 'Invalid email or password'); +// } +// }; + +// return ( +//
+//
+// +// DyoLink +// +//

+// Sign in to your account +//

+//

+// Or{' '} +// +// start your free trial +// +//

+//
+//
+//
+//
+// } +// /> +// } +// /> +//
+//
+// +// +//
+//
+// +// Forgot your password? +// +//
+//
+// {error && ( +//
+//

{error}

+//
+// )} +// +//
+//
+//
+//
+// ); +// } +'use client'; + +import { useState, useEffect } from 'react'; // ← added useEffect +import { useRouter } from 'next/navigation'; // ← added this +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import Link from 'next/link'; +import { Mail, Lock } from 'lucide-react'; + +import { useAuth } from '@/lib/hooks/useAuth'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; + +const loginSchema = z.object({ + email: z.string().email('Please enter a valid email address'), + password: z.string().min(1, 'Password is required'), +}); + +type LoginForm = z.infer; + +export default function LoginPage() { + const { login, isLoading, user, isAuthReady } = useAuth(); // ← added user + isAuthReady + const router = useRouter(); // ← added + + const [error, setError] = useState(null); + + // βœ… Redirect if user is already logged in (prevents loop & improves UX) + useEffect(() => { + if (isAuthReady && user) { + router.push('/today'); // Change to '/select-organization' if you want + } + }, [user, isAuthReady, router]); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(loginSchema), + }); + + const onSubmit = async (data: LoginForm) => { + try { + setError(null); + await login(data.email, data.password); + } catch (err: any) { + setError(err.message || 'Invalid email or password'); + } + }; + + // Optional: Show loading state while checking auth + if (!isAuthReady) { + return ( +
+

Loading...

+
+ ); + } + + return ( +
+
+ + DyoLink + +

+ Sign in to your account +

+

+ Or{' '} + + start your free trial + +

+
+ +
+
+
+ } + /> + } + /> + +
+
+ + +
+
+ + Forgot your password? + +
+
+ + {error && ( +
+

{error}

+
+ )} + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx new file mode 100644 index 0000000..9b4895a --- /dev/null +++ b/frontend/src/app/(public)/page.tsx @@ -0,0 +1,149 @@ +'use client'; + +import Link from 'next/link'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { Button } from '@/components/ui/Button'; +import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; + +export default function HomePage() { + const { user } = useAuth(); + + return ( +
+ + {/* Header */} +
+
+ +
+ DyoLink +
+ +
+ {user ? ( + + + + ) : ( + <> + + + + + + + + )} +
+ +
+
+ + {/* Hero Section */} +
+ +
+ +

+ Connect Dental Clinics & Labs + Seamlessly +

+ +

+ Streamline communication between dental professionals. Start with + a 30-day free trial, no credit card required. +

+ + {!user && ( + + + + )} +
+ + {/* Features */} +
+ } + title="For Clinics" + description="Manage patients, appointments, and send cases to labs instantly." + /> + } + title="For Labs" + description="Receive cases, track progress, and communicate with clinics." + /> + } + title="Team Management" + description="Add up to 5 team members during trial. Scale as you grow." + /> + } + title="30-Day Trial" + description="Full access to all features. No credit card required." + /> + } + title="Real-time Updates" + description="Get instant notifications on case status changes." + /> + } + title="Secure & Compliant" + description="HIPAA-compliant with enterprise-grade security." + /> +
+ +
+ + {/* Footer */} +
+
+ +
Β© 2026 DyoLink. All rights reserved.
+ +
+ + Terms & Conditions + + + Privacy Policy + +
+ +
+
+
+ ); +} + +function FeatureCard({ + icon, + title, + description, +}: { + icon: React.ReactNode; + title: string; + description: string; +}) { + return ( +
+ +
+ {icon} +
+ +

+ {title} +

+ +

+ {description} +

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx new file mode 100644 index 0000000..706651d --- /dev/null +++ b/frontend/src/app/(public)/register/page.tsx @@ -0,0 +1,265 @@ +// src/app/register/page.tsx\ +'use client'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +const registerSchema = z.object({ + name: z.string().min(2, 'Name must be at least 2 characters'), + email: z.string().email('Please enter a valid email address'), + password: z.string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') + .regex(/[0-9]/, 'Password must contain at least one number'), + confirmPassword: z.string(), + organizationName: z.string().min(2, 'Organization name must be at least 2 characters'), + organizationType: z.enum(['CLINIC', 'LAB'], { + message: 'Please select organization type', + }), +}).refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], +}); +type RegisterForm = z.infer; + +export default function RegisterPage() { + const { registerTrial, isLoading } = useAuth(); + const router = useRouter(); + const [step, setStep] = useState(1); + const [error, setError] = useState(null); + + const { + register, + handleSubmit, + watch, + formState: { errors }, + trigger, + setValue, + } = useForm({ + resolver: zodResolver(registerSchema), + mode: 'onChange', + }); + const organizationType = watch('organizationType'); + const handleNext = async () => { + const fieldsToValidate = step === 1 + ? ['name', 'email', 'password', 'confirmPassword'] + : ['organizationName', 'organizationType']; + + const isValid = await trigger(fieldsToValidate as any); + if (isValid) { + setStep(step + 1); + } + }; + const onSubmit = async (data: RegisterForm) => { + try { + setError(null); + await registerTrial( + data.email, + data.password, + data.name, + data.organizationName, + data.organizationType + ); + // No need to redirect - auth context will handle it + } catch (err: any) { + setError(err.message || 'Registration failed. Please try again.'); + } + }; + return ( +
+
+ + DyoLink + +

+ Start your 30-day free trial +

+

+ Already have an account?{' '} + + Sign in + +

+
+
+
+ {/* Progress Steps */} +
+
+
+
= 1 ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-600'}`}> + 1 +
+
= 1 ? 'text-primary-600' : 'text-gray-500' + }`}> + Account +
+
+ +
+
= 2 ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-600'}`}> + + 2 +
+
= 2 ? 'text-primary-600' : 'text-gray-500' + }`}> + Organization +
+
+
+
+ {/* Trial Info Banner */} +
+

Your trial + includes:

+
    +
  • + βœ“ Up to 5 team members +
  • +
  • + βœ“ Full access to all features +
  • +
  • + βœ“ 30 days free, no credit card + required +
  • +
+
+
+ {step === 1 && ( + <> + } + /> + } + /> + } + /> + } + /> + + + )} + {step === 2 && ( + <> + } + /> +
+ + +
+ + +
+ {errors.organizationType && ( +

{errors.organizationType.message}

+ )} +
+ {error && ( +
+

{error}

+
+ )} +
+ + +
+ + )} +
+

+ By signing up, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +

+
+
+ +
+ ); +} + + diff --git a/frontend/src/app/(public)/select-organization/page.tsx b/frontend/src/app/(public)/select-organization/page.tsx new file mode 100644 index 0000000..1117ec9 --- /dev/null +++ b/frontend/src/app/(public)/select-organization/page.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { Building2, Beaker } from 'lucide-react'; + +export default function SelectOrganizationPage() { + const { organizations, selectOrganization, isLoading } = useAuth(); + const router = useRouter(); + + // βœ… Auto-redirect if only one organization + useEffect(() => { + if (!isLoading && organizations.length === 1) { + selectOrganization(organizations[0].id); + } + }, [organizations, isLoading]); + + const getIcon = (type: string) => { + return type === 'CLINIC' + ? + : ; + }; + + if (isLoading) { + return ( +
+

Loading organizations...

+
+ ); + } + + if (!organizations.length) { + return ( +
+

No organizations found.

+
+ ); + } + + return ( +
+
+
+

+ Choose Organization +

+

+ You have access to multiple organizations. Select one to continue. +

+
+ +
+ {organizations.map((org) => ( + + ))} +
+
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/favicon.ico b/frontend/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/frontend/src/app/favicon.ico differ diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..f5a6c6f --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -0,0 +1,25 @@ +// src/app/layout.tsx +import type { Metadata } from 'next'; +import '@/styles/globals.css'; +import { AuthProvider } from '@/lib/hooks/useAuth'; + +export const metadata: Metadata = { + title: 'DyoLink - Dental Clinic & Lab Communication Hub', + description: 'Connect dental clinics and laboratories seamlessly', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx new file mode 100644 index 0000000..3ec8e46 --- /dev/null +++ b/frontend/src/components/ui/Badge.tsx @@ -0,0 +1,31 @@ +//src/components/ui/Badge.tsx +import React from 'react'; + +type BadgeVariant = 'success' | 'warning' | 'danger' | 'default'; + +interface BadgeProps { + children: React.ReactNode; + variant?: BadgeVariant; + className?: string; +} + +const variantStyles: Record = { + success: 'bg-green-50 text-green-700 border-green-200', + warning: 'bg-yellow-50 text-yellow-700 border-yellow-200', + danger: 'bg-red-50 text-red-700 border-red-200', + default: 'bg-gray-50 text-gray-700 border-gray-200', +}; + +export function Badge({ + children, + variant = 'default', + className, +}: BadgeProps) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx new file mode 100644 index 0000000..3ce5b2e --- /dev/null +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,67 @@ +// src/components/ui/Button.tsx +import React from 'react'; +import { Loader2 } from 'lucide-react'; + +type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + isLoading?: boolean; + fullWidth?: boolean; + children: React.ReactNode; +} + +export const Button: React.FC = ({ + variant = 'primary', + size = 'md', + isLoading = false, + fullWidth = false, + children, + className = '', + disabled, + ...props +}) => { + const baseClasses = + 'inline-flex items-center justify-center rounded-lg font-medium transition-all duration-200 ' + + 'focus:outline-none focus:ring-2 focus:ring-primary disabled:opacity-50 disabled:cursor-not-allowed'; + + const variantClasses: Record = { + primary: + 'bg-primary text-black hover:opacity-90', + + secondary: + 'bg-background-secondary text-text-primary hover:bg-background-card', + + outline: + 'border border-border text-text-primary hover:bg-background-card', + + danger: + 'bg-red-600 text-white hover:bg-red-700', + + ghost: + 'text-text-secondary hover:bg-background-card', + }; + + const sizeClasses: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-sm', + lg: 'px-6 py-3 text-base', + }; + + const widthClass = fullWidth ? 'w-full' : ''; + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/Input.tsx new file mode 100644 index 0000000..e650e77 --- /dev/null +++ b/frontend/src/components/ui/Input.tsx @@ -0,0 +1,67 @@ +// src/components/ui/Input.tsx +import React, { forwardRef } from 'react'; + +interface InputProps extends React.InputHTMLAttributes { + label?: string; + error?: string; + icon?: React.ReactNode; +} + +export const Input = forwardRef( + ({ label, error, icon, className = '', id, ...props }, ref) => { + const inputId = + id || `input-${Math.random().toString(36).slice(2, 9)}`; + + return ( +
+ {label && ( + + )} + +
+ {icon && ( +
+ {icon} +
+ )} + + +
+ + {error && ( +

+ {error} +

+ )} +
+ ); + } +); + +Input.displayName = 'Input'; \ No newline at end of file diff --git a/frontend/src/components/ui/OrganizationCard.tsx b/frontend/src/components/ui/OrganizationCard.tsx new file mode 100644 index 0000000..4fb7eb7 --- /dev/null +++ b/frontend/src/components/ui/OrganizationCard.tsx @@ -0,0 +1,38 @@ +// src/components/ui/OrganizationCard.tsx +import React from 'react'; +import { Building2, Beaker, ChevronRight } from 'lucide-react'; +import { Organization } from '@/types'; + +interface OrganizationCardProps { + organization: Organization; + onSelect: (id: string) => void; +} + +export const OrganizationCard: React.FC = ({ + organization, + onSelect, +}) => { + const Icon = organization.type === 'CLINIC' ? Building2 : Beaker; + const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'; + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/Sidebar.tsx b/frontend/src/components/ui/Sidebar.tsx new file mode 100644 index 0000000..7279e0d --- /dev/null +++ b/frontend/src/components/ui/Sidebar.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { usePathname, useRouter } from 'next/navigation'; +import { + LayoutDashboard, + Users, + Calendar, + UserCog, + FlaskConical, + FileText, + CreditCard +} from 'lucide-react'; + +const menu = [ + { name: 'Today', path: '/today', icon: LayoutDashboard }, + { name: 'Patients', path: '/patients', icon: Users }, + { name: 'Appointments', path: '/appointments', icon: Calendar }, + { name: 'Staff Management', path: '/staff', icon: UserCog }, + { name: 'Lab Management', path: '/lab', icon: FlaskConical }, + { name: 'Billing', path: '/billing', icon: CreditCard }, + { name: 'Reports', path: '/reports', icon: FileText }, +]; + +export default function Sidebar() { + const pathname = usePathname(); + const router = useRouter(); + + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts new file mode 100644 index 0000000..89d18c3 --- /dev/null +++ b/frontend/src/lib/api/auth.ts @@ -0,0 +1,40 @@ +// src/lib/api/auth.ts +import { apiClient } from './client'; +import { AuthResponse, TrialRegistrationData, LoginData } from '@/types'; + +export const authApi = { + // Register a new trial organization + registerTrial: async (data: TrialRegistrationData): Promise => { + const response = await apiClient.post('/auth/register', data); + return response.data; + }, + + // Login user + login: async (data: LoginData): Promise => { + const response = await apiClient.post('/auth/login', data); + return response.data; + }, + + // Get user profile + getProfile: async (): Promise => { + const response = await apiClient.get('/auth/profile'); + return response.data; + }, + + // Select organization + selectOrganization: async (organizationId: string): Promise => { + const response = await apiClient.post('/auth/select-organization', { organizationId }); + return response.data; + }, + + // Logout + logout: async (): Promise => { + await apiClient.post('/auth/logout'); + }, + + // Refresh token + refreshToken: async (refreshToken: string): Promise<{ accessToken: string }> => { + const response = await apiClient.post('/auth/refresh', { refreshToken }); + return response.data; + }, +}; \ No newline at end of file diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..196ba56 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,57 @@ +// src/lib/api/client.ts +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { ApiError } from '@/types'; + +interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean; +} + +export const apiClient = axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_URL, + withCredentials: true, // βœ… REQUIRED FOR COOKIES + headers: { + 'Content-Type': 'application/json', + }, + timeout: 10000, +}); + +// ❌ REMOVE request interceptor completely (no Authorization header) + +// βœ… Response interceptor +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const originalRequest = error.config as CustomAxiosRequestConfig; + + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + // βœ… refresh via cookie (no body needed ideally) + await axios.post( + `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`, + {}, + { withCredentials: true } + ); + + return apiClient(originalRequest); + } catch (refreshError) { + if (typeof window !== 'undefined') { + return Promise.reject(error); // βœ… just fail silently + } + return Promise.reject(refreshError); + } + } + + const apiError: ApiError = { + statusCode: error.response?.status || 500, + message: + (error.response?.data as any)?.message || + error.message || + 'An unexpected error occurred', + error: (error.response?.data as any)?.error, + }; + + return Promise.reject(apiError); + } +); \ No newline at end of file diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx new file mode 100644 index 0000000..1473056 --- /dev/null +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -0,0 +1,215 @@ +'use client'; + +import React, { createContext, useContext, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { authApi } from '@/lib/api/auth'; +import { User, Organization } from '@/types'; + +interface AuthContextType { + user: User | null; + organizations: Organization[]; + currentOrganization: Organization | null; + isLoading: boolean; + isAuthReady: boolean; // βœ… NEW + error: string | null; + registerTrial: ( + email: string, + password: string, + name: string, + organizationName: string, + organizationType: 'CLINIC' | 'LAB' + ) => Promise; + login: (email: string, password: string) => Promise; + logout: () => Promise; + selectOrganization: (orgId: string) => Promise; + clearError: () => void; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [user, setUser] = useState(null); + const [organizations, setOrganizations] = useState([]); + const [currentOrganization, setCurrentOrganization] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [isAuthReady, setIsAuthReady] = useState(false); // βœ… KEY FIX + const [error, setError] = useState(null); + + const router = useRouter(); + + useEffect(() => { + checkAuth(); + }, []); + + const checkAuth = async () => { + try { + setIsLoading(true); + + const hasSession = document.cookie.includes('accessToken'); + + if (!hasSession) { + console.log('No session β†’ skipping auth check'); + return; + } + + const response = await authApi.getProfile(); + + if (response.success) { + const userData = response.data.user; + const orgs = response.data.organizations || []; + + setUser(userData); + setOrganizations(orgs); + + const storedOrgId = localStorage.getItem('currentOrganizationId'); + if (storedOrgId && orgs.length > 0) { + const org = orgs.find(o => o.id === storedOrgId); + if (org) setCurrentOrganization(org); + } else if (orgs.length === 1) { + setCurrentOrganization(orgs[0]); + localStorage.setItem('currentOrganizationId', orgs[0].id); + } + } + } catch (err) { + console.error('Auth check failed:', err); + // Only clear state β€” DO NOT redirect here + setUser(null); + setOrganizations([]); + setCurrentOrganization(null); + } finally { + setIsLoading(false); + setIsAuthReady(true); + } + }; + + // βœ… REGISTER + const registerTrial = async ( + email: string, + password: string, + name: string, + organizationName: string, + organizationType: 'CLINIC' | 'LAB' + ) => { + try { + setIsLoading(true); + setError(null); + + const response = await authApi.registerTrial({ + email, + password, + name, + organizationName, + organizationType, + }); + + setUser(response.data.user); + setOrganizations(response.data.organizations); + + const orgs = response.data.organizations; + + if (orgs.length === 1) { + const org = orgs[0]; + setCurrentOrganization(org); + localStorage.setItem('currentOrganizationId', org.id); + router.push('/today'); + } else { + router.push('/select-organization'); + + } + + } catch (err: any) { + setError(err.message || 'Registration failed'); + throw err; + } finally { + setIsLoading(false); + } + }; + + // βœ… LOGIN + const login = async (email: string, password: string) => { + try { + setIsLoading(true); + setError(null); + + const response = await authApi.login({ email, password }); + + setUser(response.data.user); + setOrganizations(response.data.organizations); + + const orgs = response.data.organizations; + + if (orgs.length === 1) { + const org = orgs[0]; + setCurrentOrganization(org); + localStorage.setItem('currentOrganizationId', org.id); + router.push('/today'); + } else { + router.push('/select-organization'); + } + + } catch (err: any) { + setError(err.message || 'Login failed'); + throw err; + } finally { + setIsLoading(false); + } + }; + + const logout = async () => { + localStorage.clear(); + setUser(null); + setOrganizations([]); + setCurrentOrganization(null); + router.push('/'); + }; + + const selectOrganization = async (orgId: string) => { + try { + setIsLoading(true); + + const response = await authApi.selectOrganization(orgId); + + const { organization } = response.data; + + localStorage.setItem('currentOrganizationId', organization.id); + + setCurrentOrganization(organization); + + router.push('/today'); + + } catch (err: any) { + setError(err.message); + throw err; + } finally { + setIsLoading(false); + } + }; + + const clearError = () => setError(null); + + return ( + + {children} + + ); +} + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) throw new Error('useAuth must be used within AuthProvider'); + return context; +}; diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 0000000..96835b2 --- /dev/null +++ b/frontend/src/middleware.ts @@ -0,0 +1,41 @@ + +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; + +const publicRoutes = ['/', '/login', '/register', '/terms', '/privacy', '/forgot-password']; +const authOnlyRoutes = ['/login', '/register']; // routes that should NOT be accessed when logged in + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + const token = request.cookies.get('accessToken')?.value; + const isAuthenticated = !!token; + + // Always allow public routes first + if (publicRoutes.includes(pathname)) { + // If user is already logged in and tries to access login/register β†’ redirect to dashboard + if (isAuthenticated && authOnlyRoutes.includes(pathname)) { + return NextResponse.redirect(new URL('/today', request.url)); + } + return NextResponse.next(); + } + + // Protected routes: redirect to login if no token + if (!isAuthenticated) { + // Prevent loop: if somehow redirecting to login from login, just continue + if (pathname === '/login') { + return NextResponse.next(); + } + + const loginUrl = new URL('/login', request.url); + loginUrl.searchParams.set('from', pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +}; \ No newline at end of file diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 0000000..5393778 --- /dev/null +++ b/frontend/src/styles/globals.css @@ -0,0 +1,40 @@ +@import "tailwindcss"; + +/* 🎨 Design Tokens (CSS-based, no JS dependency) */ +:root { + --color-background-primary: #0B1A2B; + --color-background-secondary: #0F2236; + --color-background-card: #132A42; + + --color-text-primary: #FFFFFF; + --color-text-secondary: #A0AEC0; + + --color-border: #1F3A5F; + + --color-primary: #009CAE; +} + +/* Tailwind theme mapping */ +@theme inline { + --color-background-primary: var(--color-background-primary); + --color-background-secondary: var(--color-background-secondary); + --color-background-card: var(--color-background-card); + + --color-text-primary: var(--color-text-primary); + --color-text-secondary: var(--color-text-secondary); + + --color-border: var(--color-border); + --color-primary: var(--color-primary); +} + +/* Base styles */ +html, body { + padding: 0; + margin: 0; +} + +body { + background-color: #f9fafb; /* light gray */ + color: #111827; /* dark text */ + font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; +} \ No newline at end of file diff --git a/frontend/src/styles/tokens.ts b/frontend/src/styles/tokens.ts new file mode 100644 index 0000000..0a24d84 --- /dev/null +++ b/frontend/src/styles/tokens.ts @@ -0,0 +1,15 @@ +export const colors = { + primary: { + DEFAULT: '#00C2FF', + }, + background: { + primary: '#0B1A2B', + secondary: '#0F2236', + card: '#132A42', + }, + text: { + primary: '#FFFFFF', + secondary: '#A0AEC0', + }, + border: '#1F3A5F', +}; \ No newline at end of file diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..b3743f9 --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,46 @@ +// src/types/index.ts +export interface User { + id: string; + email: string; + name: string; +} + +export interface Organization { + id: string; + name: string; + type: 'CLINIC' | 'LAB'; + isOwner: boolean; + plan?: { + name: string; + maxUsers: number; + }; +} + +export interface AuthResponse { + success: boolean; + data: { + accessToken: string; + refreshToken: string; + user: User; + organizations: Organization[]; + }; +} + +export interface TrialRegistrationData { + email: string; + password: string; + name: string; + organizationName: string; + organizationType: 'CLINIC' | 'LAB'; +} + +export interface LoginData { + email: string; + password: string; +} + +export interface ApiError { + statusCode: number; + message: string | string[]; + error?: string; +} \ No newline at end of file diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..c051173 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -0,0 +1,19 @@ +//import type { Config } from 'tailwindcss'; +//import { colors } from './src/styles/tokens'; + +//const config: Config = { +// content: [ +// './src/**/*.{js,ts,jsx,tsx}', +// ], +// theme: { +// extend: { +// colors, +// borderRadius: { +// xl: '12px', +// '2xl': '16px', +// }, +// }, +// }, +//}; + +//export default config; \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..438b244 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] // πŸ‘ˆ CHANGED: Now points to src folder + } + }, + "include": [ + "next-env.d.ts", + "src/**/*.ts", // πŸ‘ˆ CHANGED: Looks in src folder + "src/**/*.tsx", // πŸ‘ˆ CHANGED: Looks in src folder + "src/**/*.mts", // πŸ‘ˆ CHANGED: Looks in src folder + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" +, "tailwind.config.ts" ], + "exclude": [ + "node_modules" + ] +} diff --git a/infrastructure/.env.example b/infrastructure/.env.example new file mode 100644 index 0000000..6f9a10f --- /dev/null +++ b/infrastructure/.env.example @@ -0,0 +1,22 @@ +# Docker Hub Configuration +DOCKER_USERNAME=yourdockerhub +TAG=v1.0.0 + +# Domain Configuration +DOMAIN=dyolink.com + +# Database Environment (create database.env from this) +# POSTGRES_USER=dyolink_user +# POSTGRES_PASSWORD=CHANGE_THIS_IN_PRODUCTION +# POSTGRES_DB=dyolink_db + +# Backend Environment (create backend.env from this) +# NODE_ENV=production +# JWT_SECRET=CHANGE_THIS_TO_STRONG_SECRET_32_CHARS +# DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} +# CORS_ORIGIN=https://dyolink.com + +# Frontend Environment (create frontend.env from this) +# NEXT_PUBLIC_API_URL=/api +# NEXT_PUBLIC_APP_NAME=Dyolink +# NEXT_PUBLIC_APP_URL=https://dyolink.com \ No newline at end of file diff --git a/infrastructure/database/Dockerfile b/infrastructure/database/Dockerfile new file mode 100644 index 0000000..9d89a2d --- /dev/null +++ b/infrastructure/database/Dockerfile @@ -0,0 +1,21 @@ +FROM postgres:15-alpine + +# Set timezone +ENV TZ=UTC + +# Copy initialization script +COPY init.sql /docker-entrypoint-initdb.d/ + +# Copy backup script +COPY backup.sh /usr/local/bin/backup.sh +RUN chmod +x /usr/local/bin/backup.sh + +# Create backup directory +RUN mkdir -p /backups && chown postgres:postgres /backups + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD pg_isready -U ${POSTGRES_USER} || exit 1 + +# Run as non-root +USER postgres \ No newline at end of file diff --git a/infrastructure/database/init.sql b/infrastructure/database/init.sql new file mode 100644 index 0000000..8645bbd --- /dev/null +++ b/infrastructure/database/init.sql @@ -0,0 +1,8 @@ +-- This file is optional with Prisma +-- Prisma will create all tables via migrations + +-- Only add extensions that Prisma can't create +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +-- That's it! No tables needed here. \ No newline at end of file diff --git a/infrastructure/docker-compose.prod.yml b/infrastructure/docker-compose.prod.yml new file mode 100644 index 0000000..2b99ade --- /dev/null +++ b/infrastructure/docker-compose.prod.yml @@ -0,0 +1,118 @@ +services: + postgres: + image: postgres:15-alpine + container_name: dyolink_db_prod + env_file: + - database.env + environment: + - POSTGRES_USER=${POSTGRES_USER} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} + - POSTGRES_DB=${POSTGRES_DB:-dyolink_db} + - TZ=UTC + ports: + - "5433:5432" + volumes: + - postgres_data_prod:/var/lib/postgresql/data + - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + - ./database/backups:/backups + networks: + - dyolink_network + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + backend: + image: ${DOCKER_USERNAME}/dyolink-backend:${TAG:-latest} + container_name: dyolink_backend_prod + depends_on: + postgres: + condition: service_healthy + env_file: + - backend.env + environment: + - NODE_ENV=production + - TZ=UTC + - PORT=3000 + ports: + - "4001:3000" + networks: + - dyolink_network + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {if(r.statusCode!==200)process.exit(1)})"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + frontend: + image: ${DOCKER_USERNAME}/dyolink-frontend:${TAG:-latest} + container_name: dyolink_frontend_prod + depends_on: + - backend + env_file: + - frontend.env + environment: + - NODE_ENV=production + - TZ=UTC + - PORT=3000 + ports: + - "4000:3000" + networks: + - dyolink_network + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {if(r.statusCode!==200)process.exit(1)})"] + interval: 30s + timeout: 10s + retries: 3 + + nginx: + image: nginx:alpine + container_name: dyolink_nginx_prod + depends_on: + - backend + - frontend + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./ssl:/etc/nginx/ssl:ro + - ./logs/nginx:/var/log/nginx + networks: + - dyolink_network + restart: unless-stopped + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +networks: + dyolink_network: + driver: bridge + name: dyolink_network + +volumes: + postgres_data_prod: + name: dyolink_postgres_data_prod \ No newline at end of file diff --git a/infrastructure/docker-compose.yml b/infrastructure/docker-compose.yml new file mode 100644 index 0000000..23a69e3 --- /dev/null +++ b/infrastructure/docker-compose.yml @@ -0,0 +1,92 @@ +services: + postgres: + image: postgres:15-alpine + container_name: dyolink_db_container + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: 1234 + POSTGRES_DB: dyolink_db + ports: + - "5433:5432" + volumes: + - postgres_data_dev:/var/lib/postgresql/data + - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + - ./database/backups:/backups + networks: + - dyolink_network + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + build: ../backend + container_name: dyolink_backend_container + depends_on: + postgres: + condition: service_healthy + env_file: + - ../backend/.env + environment: + - DATABASE_URL=postgresql://postgres:1234@postgres:5432/dyolink_db + - FRONTEND_URL=http://frontend:3000 + - PORT=3000 + ports: + - "4001:3000" + volumes: + - ../backend:/app:rw + - /app/node_modules + networks: + - dyolink_network + command: npm run start:dev + restart: unless-stopped + + frontend: + build: ../frontend + container_name: dyolink_frontend_container + depends_on: + - backend + env_file: + - ../frontend/.env.local + environment: + - NEXT_PUBLIC_API_URL=http://localhost:4001/api + - NEXT_PUBLIC_APP_URL=http://localhost:4000 + - PORT=3000 + ports: + - "4000:3000" + volumes: + - ../frontend:/app:rw + - /app/node_modules + - /app/.next + networks: + - dyolink_network + command: npm run dev + restart: unless-stopped + + nginx: + build: ./nginx + container_name: dyolink_nginx_container + depends_on: + - backend + - frontend + ports: + - "8080:80" + - "8443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./ssl:/etc/nginx/ssl:ro + - ./logs/nginx:/var/log/nginx + networks: + - dyolink_network + restart: unless-stopped + +networks: + dyolink_network: + driver: bridge + name: dyolink_network + +volumes: + postgres_data_dev: + name: dyolink_postgres_data_dev \ No newline at end of file diff --git a/infrastructure/nginx/Dockerfile b/infrastructure/nginx/Dockerfile new file mode 100644 index 0000000..a2a3e3f --- /dev/null +++ b/infrastructure/nginx/Dockerfile @@ -0,0 +1,19 @@ +FROM nginx:alpine + +# Remove default configuration +RUN rm /etc/nginx/conf.d/default.conf + +# Copy custom configuration +COPY nginx.conf /etc/nginx/conf.d/ + +# Create log directory +RUN mkdir -p /var/log/nginx && \ + chown -R nginx:nginx /var/log/nginx && \ + chmod -R 755 /var/log/nginx + +# Switch to non-root user +USER nginx + +EXPOSE 80 443 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/infrastructure/nginx/nginx.conf b/infrastructure/nginx/nginx.conf new file mode 100644 index 0000000..52a66d9 --- /dev/null +++ b/infrastructure/nginx/nginx.conf @@ -0,0 +1,103 @@ +# Upstream servers using Docker service names (internal network) +upstream dyolink_backend { + server backend:3000; + keepalive 32; +} + +upstream dyolink_frontend { + server frontend:3000; + keepalive 32; +} + +# HTTP Server (redirects to HTTPS) +server { + listen 80; + listen [::]:80; + server_name dyolink.com www.dyolink.com; + + # Redirect all HTTP to HTTPS + return 301 https://$server_name$request_uri; +} + +# HTTPS Server +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name dyolink.com www.dyolink.com; + + # SSL Configuration - Replace with your actual certificates + ssl_certificate /etc/nginx/ssl/dyolink.com.crt; + ssl_certificate_key /etc/nginx/ssl/dyolink.com.key; + + # SSL Security + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied expired no-cache no-store private auth; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json; + gzip_disable "MSIE [1-6]\."; + + # Client settings + client_max_body_size 50M; + client_body_timeout 12; + client_header_timeout 12; + keepalive_timeout 15; + send_timeout 10; + + # Frontend (Next.js) + location / { + proxy_pass http://dyolink_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + } + + # Backend API + location /api { + proxy_pass http://dyolink_backend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300; + proxy_connect_timeout 300; + } + + # Health check endpoint (no logging) + location /health { + access_log off; + return 200 "healthy\n"; + add_header Content-Type text/plain; + } + + # Deny access to hidden files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } +} \ No newline at end of file diff --git a/infrastructure/scripts/backup.sh b/infrastructure/scripts/backup.sh new file mode 100644 index 0000000..df3e1ee --- /dev/null +++ b/infrastructure/scripts/backup.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +BACKUP_DIR="/backups" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_NAME="dyolink_backup_$TIMESTAMP" +BACKUP_FILE="$BACKUP_DIR/$BACKUP_NAME.sql.gz" + +echo "[$(date)] Starting database backup: $BACKUP_NAME" + +# Perform backup +pg_dumpall -U $POSTGRES_USER | gzip > $BACKUP_FILE + +if [ $? -eq 0 ]; then + BACKUP_SIZE=$(du -h $BACKUP_FILE | cut -f1) + echo "[$(date)] βœ… Backup completed: $BACKUP_NAME ($BACKUP_SIZE)" + + # Keep only last 7 days of backups + find $BACKUP_DIR -name "dyolink_backup_*.sql.gz" -mtime +7 -delete + echo "[$(date)] Cleaned up backups older than 7 days" +else + echo "[$(date)] ❌ Backup failed!" + exit 1 +fi \ No newline at end of file diff --git a/infrastructure/scripts/build-and-push.sh b/infrastructure/scripts/build-and-push.sh new file mode 100644 index 0000000..c529a50 --- /dev/null +++ b/infrastructure/scripts/build-and-push.sh @@ -0,0 +1,105 @@ +#!/bin/bash +set -e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" +echo -e "${BLUE}β•‘ Dyolink - Build & Push β•‘${NC}" +echo -e "${BLUE}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" + +# Load environment +if [ -f .env ]; then + source .env + echo -e "${GREEN}βœ“ Loaded .env${NC}" +fi + +# Get version +read -p "Enter version tag (e.g., v1.0.0, default: latest): " TAG +TAG=${TAG:-latest} +echo -e "${YELLOW}Using tag: ${TAG}${NC}" + +# Get Docker Hub username +read -p "Docker Hub username [${DOCKER_USERNAME:-yourdockerhub}]: " USERNAME +USERNAME=${USERNAME:-${DOCKER_USERNAME:-yourdockerhub}} + +# Check Docker +if ! docker info > /dev/null 2>&1; then + echo -e "${RED}βœ— Docker is not running!${NC}" + exit 1 +fi + +# Login +echo -e "${YELLOW}Logging into Docker Hub...${NC}" +docker login --username $USERNAME + +echo -e "\n${GREEN}=== Building Images ===${NC}" + +# Build backend +echo -e "${YELLOW}Building backend...${NC}" +docker build -t $USERNAME/dyolink-backend:$TAG -t $USERNAME/dyolink-backend:latest ../backend +echo -e "${GREEN}βœ“ Backend built${NC}" + +# Build frontend +echo -e "${YELLOW}Building frontend...${NC}" +docker build -t $USERNAME/dyolink-frontend:$TAG -t $USERNAME/dyolink-frontend:latest ../frontend +echo -e "${GREEN}βœ“ Frontend built${NC}" + +echo -e "\n${GREEN}=== Pushing to Docker Hub ===${NC}" + +# Push backend +echo -e "${YELLOW}Pushing backend...${NC}" +docker push $USERNAME/dyolink-backend:$TAG +docker push $USERNAME/dyolink-backend:latest +echo -e "${GREEN}βœ“ Backend pushed${NC}" + +# Push frontend +echo -e "${YELLOW}Pushing frontend...${NC}" +docker push $USERNAME/dyolink-frontend:$TAG +docker push $USERNAME/dyolink-frontend:latest +echo -e "${GREEN}βœ“ Frontend pushed${NC}" + +# Create deployment package +echo -e "\n${YELLOW}Creating deployment package...${NC}" +DEPLOY_DIR="dyolink-deploy-$TAG" +mkdir -p $DEPLOY_DIR +cp docker-compose.prod.yml $DEPLOY_DIR/ +cp -r nginx/nginx.conf $DEPLOY_DIR/ +cp database/init.sql $DEPLOY_DIR/ +cp scripts/deploy.sh $DEPLOY_DIR/ +cp .env.example $DEPLOY_DIR/ + +# Create env templates +echo "# Database" > $DEPLOY_DIR/database.env.example +echo "POSTGRES_USER=dyolink_user" >> $DEPLOY_DIR/database.env.example +echo "POSTGRES_PASSWORD=CHANGE_ME" >> $DEPLOY_DIR/database.env.example +echo "POSTGRES_DB=dyolink_db" >> $DEPLOY_DIR/database.env.example + +echo "# Backend" > $DEPLOY_DIR/backend.env.example +echo "NODE_ENV=production" >> $DEPLOY_DIR/backend.env.example +echo "JWT_SECRET=CHANGE_ME_32_CHARS_MINIMUM" >> $DEPLOY_DIR/backend.env.example +echo "DATABASE_URL=postgresql://\${POSTGRES_USER}:\${POSTGRES_PASSWORD}@postgres:5432/\${POSTGRES_DB}" >> $DEPLOY_DIR/backend.env.example +echo "CORS_ORIGIN=https://dyolink.com" >> $DEPLOY_DIR/backend.env.example +echo "FRONTEND_URL=https://dyolink.com" >> $DEPLOY_DIR/backend.env.example + +echo "# Frontend" > $DEPLOY_DIR/frontend.env.example +echo "NEXT_PUBLIC_API_URL=/api" >> $DEPLOY_DIR/frontend.env.example +echo "NEXT_PUBLIC_APP_NAME=Dyolink" >> $DEPLOY_DIR/frontend.env.example +echo "NEXT_PUBLIC_APP_URL=https://dyolink.com" >> $DEPLOY_DIR/frontend.env.example + +# Package +tar -czf $DEPLOY_DIR.tar.gz $DEPLOY_DIR/ +rm -rf $DEPLOY_DIR +echo -e "${GREEN}βœ“ Created: ${DEPLOY_DIR}.tar.gz${NC}" + +echo -e "\n${BLUE}╔════════════════════════════════════════╗${NC}" +echo -e "${BLUE}β•‘ Build Complete! β•‘${NC}" +echo -e "${BLUE}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" +echo -e "${GREEN}Pushed:${NC}" +echo -e " β€’ ${USERNAME}/dyolink-backend:${TAG}" +echo -e " β€’ ${USERNAME}/dyolink-frontend:${TAG}" +echo -e "\n${YELLOW}Next: scp ${DEPLOY_DIR}.tar.gz user@server:/tmp/${NC}" \ No newline at end of file diff --git a/infrastructure/scripts/deploy.sh b/infrastructure/scripts/deploy.sh new file mode 100644 index 0000000..a20cc03 --- /dev/null +++ b/infrastructure/scripts/deploy.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -e + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' + +echo -e "${BLUE}╔════════════════════════════════════════╗${NC}" +echo -e "${BLUE}β•‘ Dyolink - Deploy Tool β•‘${NC}" +echo -e "${BLUE}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" + +# Load environment +if [ -f .env ]; then + source .env + echo -e "${GREEN}βœ“ Loaded .env${NC}" +fi + +# Check for env files +ENV_FILES=("database.env" "backend.env" "frontend.env") +for env_file in "${ENV_FILES[@]}"; do + if [ ! -f "$env_file" ]; then + echo -e "${YELLOW}⚠ $env_file not found, creating from example${NC}" + if [ -f "${env_file}.example" ]; then + cp "${env_file}.example" "$env_file" + echo -e "${RED}❕ Edit $env_file with production values!${NC}" + read -p "Continue? (y/n): " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi + fi + fi +done + +# Create directories +mkdir -p logs/nginx database/backups ssl + +# Get version +read -p "Enter version to deploy (default: ${TAG:-latest}): " DEPLOY_TAG +DEPLOY_TAG=${DEPLOY_TAG:-${TAG:-latest}} +export TAG=$DEPLOY_TAG +export DOCKER_USERNAME=${DOCKER_USERNAME:-yourdockerhub} + +# Pull images +echo -e "${YELLOW}Pulling images from Docker Hub...${NC}" +docker-compose -f docker-compose.prod.yml pull + +# Stop old +echo -e "${YELLOW}Stopping old containers...${NC}" +docker-compose -f docker-compose.prod.yml down + +# Start new +echo -e "${YELLOW}Starting containers...${NC}" +docker-compose -f docker-compose.prod.yml up -d + +# Wait for health +echo -e "${YELLOW}Waiting for services...${NC}" +sleep 10 + +# Show status +echo -e "\n${GREEN}=== Status ===${NC}" +docker-compose -f docker-compose.prod.yml ps + +echo -e "\n${BLUE}╔════════════════════════════════════════╗${NC}" +echo -e "${BLUE}β•‘ Deployment Complete! β•‘${NC}" +echo -e "${BLUE}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" \ No newline at end of file diff --git a/infrastructure/scripts/logs.sh b/infrastructure/scripts/logs.sh new file mode 100644 index 0000000..bca943f --- /dev/null +++ b/infrastructure/scripts/logs.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +echo "=== Dyolink Health Monitor ===" +echo "Time: $(date)" +echo "" + +# Check containers +echo "Container Status:" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Health}}" | grep dyolink + +# Check resources +echo -e "\nResource Usage:" +docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" | grep dyolink + +# Check disk +DISK=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%') +if [ $DISK -gt 80 ]; then + echo -e "\n${RED}⚠ WARNING: Disk usage at ${DISK}%${NC}" +else + echo -e "\n${GREEN}βœ“ Disk usage: ${DISK}%${NC}" +fi + +# Check API +echo -e "\nAPI Health:" +curl -s -o /dev/null -w "Backend API: %{http_code}\n" http://localhost:3001/api/health || echo "Backend API: DOWN" +curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:3002 || echo "Frontend: DOWN" \ No newline at end of file diff --git a/infrastructure/scripts/manage.sh b/infrastructure/scripts/manage.sh new file mode 100644 index 0000000..ec85fe5 --- /dev/null +++ b/infrastructure/scripts/manage.sh @@ -0,0 +1,127 @@ +#!/bin/bash + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +BLUE='\033[0;34m' +NC='\033[0m' + +COMPOSE_FILE="docker-compose.prod.yml" + +show_help() { + echo -e "${BLUE}Dyolink Management Tool${NC}" + echo "" + echo "Usage: $0 {start|stop|restart|logs|status|backup|update|cleanup|monitor}" + echo "" + echo "Commands:" + echo " start - Start all services" + echo " stop - Stop all services" + echo " restart - Restart all services" + echo " logs - Show logs (add -f to follow)" + echo " status - Show container status" + echo " backup - Backup database" + echo " update - Pull and restart with latest images" + echo " cleanup - Clean old containers/images" + echo " monitor - Show health status" +} + +check_compose() { + if [ ! -f "$COMPOSE_FILE" ]; then + echo -e "${RED}Error: $COMPOSE_FILE not found${NC}" + exit 1 + fi +} + +case "$1" in + start) + check_compose + echo -e "${GREEN}Starting services...${NC}" + docker-compose -f $COMPOSE_FILE up -d + ;; + stop) + check_compose + echo -e "${YELLOW}Stopping services...${NC}" + docker-compose -f $COMPOSE_FILE down + ;; + restart) + check_compose + echo -e "${YELLOW}Restarting services...${NC}" + docker-compose -f $COMPOSE_FILE restart + ;; + logs) + check_compose + echo -e "${GREEN}Showing logs...${NC}" + docker-compose -f $COMPOSE_FILE logs ${@:2} + ;; + status) + check_compose + echo -e "${GREEN}Container Status:${NC}" + docker-compose -f $COMPOSE_FILE ps + echo -e "\n${GREEN}Running Containers:${NC}" + docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep dyolink + ;; + backup) + check_compose + echo -e "${GREEN}Backing up database...${NC}" + TIMESTAMP=$(date +%Y%m%d_%H%M%S) + BACKUP_FILE="dyolink_backup_$TIMESTAMP.sql.gz" + docker-compose -f $COMPOSE_FILE exec -T postgres pg_dumpall -U postgres | gzip > $BACKUP_FILE + if [ $? -eq 0 ]; then + SIZE=$(du -h $BACKUP_FILE | cut -f1) + echo -e "${GREEN}βœ“ Backup saved: $BACKUP_FILE ($SIZE)${NC}" + else + echo -e "${RED}βœ— Backup failed${NC}" + fi + ;; + update) + check_compose + echo -e "${GREEN}Pulling latest images...${NC}" + docker-compose -f $COMPOSE_FILE pull + docker-compose -f $COMPOSE_FILE up -d + echo -e "${GREEN}βœ“ Update complete!${NC}" + ;; + cleanup) + echo -e "${YELLOW}Cleaning up Docker resources...${NC}" + docker container prune -f + docker image prune -f + docker volume prune -f + docker network prune -f + echo -e "${GREEN}βœ“ Cleanup complete!${NC}" + ;; + monitor) + check_compose + echo -e "${GREEN}Health Monitoring:${NC}\n" + + SERVICES=("postgres" "backend" "frontend" "nginx") + ALL_HEALTHY=true + + for SERVICE in "${SERVICES[@]}"; do + STATUS=$(docker-compose -f $COMPOSE_FILE ps $SERVICE 2>/dev/null | tail -1) + if echo "$STATUS" | grep -q "Up"; then + echo -e " ${GREEN}βœ“${NC} $SERVICE: Running" + else + echo -e " ${RED}βœ—${NC} $SERVICE: Not running" + ALL_HEALTHY=false + fi + done + + echo "" + DISK=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%') + if [ $DISK -gt 80 ]; then + echo -e "${RED}⚠ Disk usage: ${DISK}%${NC}" + ALL_HEALTHY=false + else + echo -e "${GREEN}βœ“ Disk usage: ${DISK}%${NC}" + fi + + if [ "$ALL_HEALTHY" = true ]; then + echo -e "\n${GREEN}βœ… All systems operational${NC}" + else + echo -e "\n${YELLOW}⚠ Issues detected${NC}" + fi + ;; + *) + show_help + exit 1 + ;; +esac \ No newline at end of file diff --git a/infrastructure/scripts/monitor.sh b/infrastructure/scripts/monitor.sh new file mode 100644 index 0000000..bca943f --- /dev/null +++ b/infrastructure/scripts/monitor.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' + +echo "=== Dyolink Health Monitor ===" +echo "Time: $(date)" +echo "" + +# Check containers +echo "Container Status:" +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Health}}" | grep dyolink + +# Check resources +echo -e "\nResource Usage:" +docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" | grep dyolink + +# Check disk +DISK=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%') +if [ $DISK -gt 80 ]; then + echo -e "\n${RED}⚠ WARNING: Disk usage at ${DISK}%${NC}" +else + echo -e "\n${GREEN}βœ“ Disk usage: ${DISK}%${NC}" +fi + +# Check API +echo -e "\nAPI Health:" +curl -s -o /dev/null -w "Backend API: %{http_code}\n" http://localhost:3001/api/health || echo "Backend API: DOWN" +curl -s -o /dev/null -w "Frontend: %{http_code}\n" http://localhost:3002 || echo "Frontend: DOWN" \ No newline at end of file