From 26bd35ae3c3d5cd72688c2a09e1b5e875def1ea4 Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 23 Apr 2026 15:33:11 +0330 Subject: [PATCH] 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 --- .dockerignore | 21 + .gitignore | 125 ++++ backend/.dockerignore | 13 + backend/.env.example | 23 + backend/.gitignore | 56 ++ backend/.prettierrc | 4 + backend/Dockerfile | 92 +++ backend/README.md | 98 +++ backend/SETUP.md | 95 +++ backend/docker-entrypoint.sh | 76 ++ backend/eslint.config.mjs | 35 + backend/nest-cli.json | 8 + backend/output.txt | Bin 0 -> 186574 bytes backend/package.json | 114 +++ .../20260216154742_init_schema/migration.sql | 191 +++++ backend/prisma/migrations/migration_lock.toml | 3 + backend/prisma/prisma.module.ts | 9 + backend/prisma/prisma.service.ts | 22 + backend/prisma/schema.prisma | 172 +++++ backend/prisma/seed.ts | 122 ++++ backend/src/admin/admin.module.ts | 109 +++ backend/src/admin/components.ts | 11 + backend/src/admin/dashboard.tsx | 32 + backend/src/app.controller.spec.ts | 22 + backend/src/app.controller.ts | 25 + backend/src/app.module.ts | 23 + backend/src/app.service.ts | 8 + backend/src/configs/configurations.ts | 53 ++ backend/src/main.ts | 81 +++ backend/src/modules/auth/auth.controller.ts | 177 +++++ backend/src/modules/auth/auth.module.ts | 33 + backend/src/modules/auth/auth.service.ts | 668 ++++++++++++++++++ backend/src/modules/auth/dto/login.dto.ts | 23 + backend/src/modules/auth/dto/oauth.dto.ts | 6 + backend/src/modules/auth/dto/register.dto.ts | 19 + .../src/modules/auth/guards/jwt-auth.guard.ts | 10 + .../modules/auth/guards/local-auth.guard.ts | 10 + .../auth/interfaces/jwt-payload.interface.ts | 7 + .../modules/auth/strategies/jwt.strategy.ts | 36 + .../modules/auth/strategies/local.strategy.ts | 20 + backend/test/app.e2e-spec.ts | 25 + backend/test/jest-e2e.json | 9 + backend/tsconfig.build.json | 4 + backend/tsconfig.json | 25 + frontend/.dockerignore | 17 + frontend/.gitignore | 45 ++ frontend/Dockerfile | 72 ++ frontend/README.md | 36 + frontend/docker-entrypoint.sh | 23 + frontend/eslint.config.mjs | 18 + frontend/next.config.ts | 33 + frontend/package.json | 35 + frontend/postcss.config.mjs | 7 + frontend/public/file.svg | 1 + frontend/public/globe.svg | 1 + frontend/public/next.svg | 1 + frontend/public/vercel.svg | 1 + frontend/public/window.svg | 1 + frontend/src/app/(dashboard)/billing/page.tsx | 202 ++++++ frontend/src/app/(dashboard)/layout.tsx | 73 ++ frontend/src/app/(dashboard)/today/page.tsx | 36 + frontend/src/app/(public)/login/page.tsx | 243 +++++++ frontend/src/app/(public)/page.tsx | 149 ++++ frontend/src/app/(public)/register/page.tsx | 265 +++++++ .../app/(public)/select-organization/page.tsx | 82 +++ frontend/src/app/favicon.ico | Bin 0 -> 25931 bytes frontend/src/app/layout.tsx | 25 + frontend/src/components/ui/Badge.tsx | 31 + frontend/src/components/ui/Button.tsx | 67 ++ frontend/src/components/ui/Input.tsx | 67 ++ .../src/components/ui/OrganizationCard.tsx | 38 + frontend/src/components/ui/Sidebar.tsx | 57 ++ frontend/src/lib/api/auth.ts | 40 ++ frontend/src/lib/api/client.ts | 57 ++ frontend/src/lib/hooks/useAuth.tsx | 215 ++++++ frontend/src/middleware.ts | 41 ++ frontend/src/styles/globals.css | 40 ++ frontend/src/styles/tokens.ts | 15 + frontend/src/types/index.ts | 46 ++ frontend/tailwind.config.ts | 19 + frontend/tsconfig.json | 42 ++ infrastructure/.env.example | 22 + infrastructure/database/Dockerfile | 21 + infrastructure/database/init.sql | 8 + infrastructure/docker-compose.prod.yml | 118 ++++ infrastructure/docker-compose.yml | 92 +++ infrastructure/nginx/Dockerfile | 19 + infrastructure/nginx/nginx.conf | 103 +++ infrastructure/scripts/backup.sh | 24 + infrastructure/scripts/build-and-push.sh | 105 +++ infrastructure/scripts/deploy.sh | 68 ++ infrastructure/scripts/logs.sh | 32 + infrastructure/scripts/manage.sh | 127 ++++ infrastructure/scripts/monitor.sh | 32 + 94 files changed, 5627 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/.gitignore create mode 100644 backend/.prettierrc create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/SETUP.md create mode 100644 backend/docker-entrypoint.sh create mode 100644 backend/eslint.config.mjs create mode 100644 backend/nest-cli.json create mode 100644 backend/output.txt create mode 100644 backend/package.json create mode 100644 backend/prisma/migrations/20260216154742_init_schema/migration.sql create mode 100644 backend/prisma/migrations/migration_lock.toml create mode 100644 backend/prisma/prisma.module.ts create mode 100644 backend/prisma/prisma.service.ts create mode 100644 backend/prisma/schema.prisma create mode 100644 backend/prisma/seed.ts create mode 100644 backend/src/admin/admin.module.ts create mode 100644 backend/src/admin/components.ts create mode 100644 backend/src/admin/dashboard.tsx create mode 100644 backend/src/app.controller.spec.ts create mode 100644 backend/src/app.controller.ts create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/app.service.ts create mode 100644 backend/src/configs/configurations.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/modules/auth/auth.controller.ts create mode 100644 backend/src/modules/auth/auth.module.ts create mode 100644 backend/src/modules/auth/auth.service.ts create mode 100644 backend/src/modules/auth/dto/login.dto.ts create mode 100644 backend/src/modules/auth/dto/oauth.dto.ts create mode 100644 backend/src/modules/auth/dto/register.dto.ts create mode 100644 backend/src/modules/auth/guards/jwt-auth.guard.ts create mode 100644 backend/src/modules/auth/guards/local-auth.guard.ts create mode 100644 backend/src/modules/auth/interfaces/jwt-payload.interface.ts create mode 100644 backend/src/modules/auth/strategies/jwt.strategy.ts create mode 100644 backend/src/modules/auth/strategies/local.strategy.ts create mode 100644 backend/test/app.e2e-spec.ts create mode 100644 backend/test/jest-e2e.json create mode 100644 backend/tsconfig.build.json create mode 100644 backend/tsconfig.json create mode 100644 frontend/.dockerignore create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/docker-entrypoint.sh create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/next.config.ts create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.mjs create mode 100644 frontend/public/file.svg create mode 100644 frontend/public/globe.svg create mode 100644 frontend/public/next.svg create mode 100644 frontend/public/vercel.svg create mode 100644 frontend/public/window.svg create mode 100644 frontend/src/app/(dashboard)/billing/page.tsx create mode 100644 frontend/src/app/(dashboard)/layout.tsx create mode 100644 frontend/src/app/(dashboard)/today/page.tsx create mode 100644 frontend/src/app/(public)/login/page.tsx create mode 100644 frontend/src/app/(public)/page.tsx create mode 100644 frontend/src/app/(public)/register/page.tsx create mode 100644 frontend/src/app/(public)/select-organization/page.tsx create mode 100644 frontend/src/app/favicon.ico create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/components/ui/Badge.tsx create mode 100644 frontend/src/components/ui/Button.tsx create mode 100644 frontend/src/components/ui/Input.tsx create mode 100644 frontend/src/components/ui/OrganizationCard.tsx create mode 100644 frontend/src/components/ui/Sidebar.tsx create mode 100644 frontend/src/lib/api/auth.ts create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/hooks/useAuth.tsx create mode 100644 frontend/src/middleware.ts create mode 100644 frontend/src/styles/globals.css create mode 100644 frontend/src/styles/tokens.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.json create mode 100644 infrastructure/.env.example create mode 100644 infrastructure/database/Dockerfile create mode 100644 infrastructure/database/init.sql create mode 100644 infrastructure/docker-compose.prod.yml create mode 100644 infrastructure/docker-compose.yml create mode 100644 infrastructure/nginx/Dockerfile create mode 100644 infrastructure/nginx/nginx.conf create mode 100644 infrastructure/scripts/backup.sh create mode 100644 infrastructure/scripts/build-and-push.sh create mode 100644 infrastructure/scripts/deploy.sh create mode 100644 infrastructure/scripts/logs.sh create mode 100644 infrastructure/scripts/manage.sh create mode 100644 infrastructure/scripts/monitor.sh 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 0000000000000000000000000000000000000000..2963168034aaa3777640ee792ce6abde303bc9be GIT binary patch literal 186574 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 C-;PoM literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 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