Initial commit: Full project structure

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

21
.dockerignore Normal file
View File

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

125
.gitignore vendored Normal file
View File

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

13
backend/.dockerignore Normal file
View File

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

23
backend/.env.example Normal file
View File

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

56
backend/.gitignore vendored Normal file
View File

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

4
backend/.prettierrc Normal file
View File

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

92
backend/Dockerfile Normal file
View File

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

98
backend/README.md Normal file
View File

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

95
backend/SETUP.md Normal file
View File

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

View File

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

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

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

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

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

BIN
backend/output.txt Normal file

Binary file not shown.

114
backend/package.json Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

25
backend/tsconfig.json Normal file
View File

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

17
frontend/.dockerignore Normal file
View File

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

45
frontend/.gitignore vendored Normal file
View File

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

72
frontend/Dockerfile Normal file
View File

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

36
frontend/README.md Normal file
View File

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

View File

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

View File

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

33
frontend/next.config.ts Normal file
View File

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

35
frontend/package.json Normal file
View File

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

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -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 (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Billing</h1>
<Button variant="primary" className="flex items-center gap-2">
<Plus className="h-4 w-4" />
New Invoice
</Button>
</div>
{/* Stats Cards - Matching your design */}
<div className="grid grid-cols-4 gap-4">
<StatCard
title="Total Invoices"
count={stats.total.count}
amount={stats.total.amount}
color="blue"
/>
<StatCard
title="Unpaid Invoices"
count={stats.unpaid.count}
amount={stats.unpaid.amount}
color="yellow"
/>
<StatCard
title="Paid Invoices"
count={stats.paid.count}
amount={stats.paid.amount}
color="green"
/>
<StatCard
title="Overdue Invoices"
count={stats.overdue.count}
amount={stats.overdue.amount}
color="red"
/>
</div>
{/* Filters */}
<div className="bg-white p-4 rounded-xl shadow-sm border">
<div className="flex gap-4 items-center">
<div className="flex-1">
<Input
placeholder="Search patients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
icon={<Search className="h-4 w-4 text-gray-400" />}
/>
</div>
<div className="flex gap-2">
{['all', 'paid', 'unpaid', 'overdue'].map((status) => (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-4 py-2 rounded-lg text-sm font-medium capitalize ${statusFilter === status
? 'bg-primary-50 text-primary-700 border border-primary-200'
: 'text-gray-600 hover:bg-gray-50'
}`}
>
{status}
</button>
))}
</div>
</div>
</div>
{/* Invoices Table - Matching your design */}
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50 border-b">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Invoice ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Patient name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Service
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Paid
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{invoices.map((invoice) => (
<tr key={invoice.id} className="hover:bg-gray-50">
<td className="px-6 py-4 text-sm font-medium text-gray-900">
{invoice.id}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
{invoice.patient}
</td>
<td className="px-6 py-4 text-sm text-gray-500">
{invoice.date}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
{invoice.service}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
${invoice.amount}
</td>
<td className="px-6 py-4 text-sm text-gray-900">
${invoice.paid}
</td>
<td className="px-6 py-4">
<Badge
variant={statusColors[invoice.status as keyof typeof statusColors]}
className="capitalize"
>
{invoice.status}
</Badge>
</td>
<td className="px-6 py-4">
<button className="text-primary-600 hover:text-primary-800 text-sm">
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
{/* Pagination - Matching your design */}
<div className="px-6 py-4 border-t flex justify-between items-center bg-gray-50">
<button className="text-sm text-gray-600 hover:text-gray-900">
Previous
</button>
<div className="text-sm text-gray-600">
Page 1 of 10
</div>
<button className="text-sm text-gray-600 hover:text-gray-900">
Next
</button>
</div>
</div>
</div>
);
}
function StatCard({ title, count, amount, color }: StatCardProps) {
const colors: Record<StatCardColor, string> = {
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 (
<div className={`p-4 rounded-xl border ${colors[color]}`}>
<p className="text-sm font-medium opacity-80">{title}</p>
<p className="text-2xl font-bold mt-1">{count}</p>
<p className="text-sm font-medium mt-1">
${amount.toLocaleString()}
</p>
</div>
);
}

View File

@@ -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 (
<div className="h-screen flex items-center justify-center">
Loading app...
</div>
);
}
if (!user || !currentOrganization) {
return (
<div className="h-screen flex items-center justify-center">
Loading workspace...
</div>
);
}
return (
<div className="flex h-screen bg-[#020d1a] text-white">
<Sidebar />
<div className="flex-1 flex flex-col">
<header className="flex justify-between px-6 py-4 border-b border-white/10">
<h2>{currentOrganization.name}</h2>
<div className="flex gap-4">
<span>{user.name}</span>
<button onClick={logout}>
<LogOut />
</button>
</div>
</header>
<main className="p-6 flex-1 overflow-y-auto">
{children} {/* 🔥 THIS CHANGES */}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
export default function TodayPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-6">
Welcome back Babak !!
</h1>
<div className="grid grid-cols-4 gap-4">
<Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" />
<Card title="Active Patients" value="675" />
<Card title="New Lab Case" value="5" sub="35 ↑" />
<Card title="Today invoices" value="1200$" sub="21,300 $" />
</div>
</div>
);
}
function Card({
title,
value,
sub,
}: {
title: string;
value: string;
sub?: string;
}) {
return (
<div className="bg-white/5 border border-white/10 p-4 rounded-xl">
<p className="text-sm text-gray-300">{title}</p>
<p className="text-2xl font-bold mt-2">{value}</p>
{sub && <p className="text-xs text-gray-400 mt-1">{sub}</p>}
</div>
);
}

View File

@@ -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<typeof loginSchema>;
// export default function LoginPage() {
// const { login, isLoading } = useAuth();
// const [error, setError] = useState<string | null>(null);
// const {
// register,
// handleSubmit,
// formState: { errors },
// } = useForm<LoginForm>({
// 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 (
// <div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
// <div className="sm:mx-auto sm:w-full sm:max-w-md">
// <Link href="/" className="flex justify-center">
// <span className="text-3xl font-bold text-primary-600">DyoLink</span>
// </Link>
// <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
// Sign in to your account
// </h2>
// <p className="mt-2 text-center text-sm text-gray-600">
// Or{' '}
// <Link href="/register" className="font-medium text-primary-600 hover:text-primary-500">
// start your free trial
// </Link>
// </p>
// </div>
// <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
// <div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
// <form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
// <Input
// label="Email address"
// {...register('email')}
// type="email"
// placeholder="you@example.com"
// error={errors.email?.message}
// icon={<Mail className="h-5 w-5 text-gray-400" />}
// />
// <Input
// label="Password"
// {...register('password')}
// type="password"
// placeholder="••••••••"
// error={errors.password?.message}
// icon={<Lock className="h-5 w-5 text-gray-400" />}
// />
// <div className="flex items-center justify-between">
// <div className="flex items-center">
// <input
// id="remember-me"
// name="remember-me"
// type="checkbox"
// className="h-4 w-4 text-primary-600 focus:ring-primary-500border-gray-300 rounded"
// />
// <label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
// Remember me
// </label>
// </div>
// <div className="text-sm">
// <Link href="/forgot-password" className="font-medium text-primary-600 hover:text-primary-500">
// Forgot your password?
// </Link>
// </div>
// </div>
// {error && (
// <div className="p-3 bg-red-50 border border-red-200 rounded-lg">
// <p className="text-sm text-red-600">{error}</p>
// </div>
// )}
// <Button
// type="submit"
// variant="primary"
// isLoading={isLoading}
// fullWidth
// >
// Sign in
// </Button>
// </form>
// </div>
// </div>
// </div>
// );
// }
'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<typeof loginSchema>;
export default function LoginPage() {
const { login, isLoading, user, isAuthReady } = useAuth(); // ← added user + isAuthReady
const router = useRouter(); // ← added
const [error, setError] = useState<string | null>(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<LoginForm>({
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 (
<div className="min-h-screen flex items-center justify-center">
<p>Loading...</p>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link href="/" className="flex justify-center">
<span className="text-3xl font-bold text-primary-600">DyoLink</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-exbol text-gray-900">
Sign in to your account
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Or{' '}
<Link href="/register" className="font-medium text-primary-600 hover:text-primary-500">
start your free trial
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
<Input
label="Email address"
{...register('email')}
type="email"
placeholder="you@example.com"
error={errors.email?.message}
icon={<Mail className="h-5 w-5 text-gray-400" />}
/>
<Input
label="Password"
{...register('password')}
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 text-gray-400" />}
/>
<div className="flex items-center justify-between">
<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
Remember me
</label>
</div>
<div className="text-sm">
<Link href="/forgot-password" className="font-medium text-primary-600 hover:text-primary-500">
Forgot your password?
</Link>
</div>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<Button
type="submit"
variant="primary"
isLoading={isLoading}
fullWidth
>
Sign in
</Button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div className="min-h-screen bg-background-primary">
{/* Header */}
<header className="border-b border-border bg-background-secondary/80 backdrop-blur-sm fixed top-0 w-full z-10">
<div className="container mx-auto px-4 py-4 flex justify-between items-center">
<div className="text-2xl font-semibold text-primary">
DyoLink
</div>
<div className="flex gap-3">
{user ? (
<Link href="/today">
<Button variant="primary">Dashboard</Button>
</Link>
) : (
<>
<Link href="/login">
<Button variant="outline">Login</Button>
</Link>
<Link href="/register">
<Button variant="primary">Start Trial</Button>
</Link>
</>
)}
</div>
</div>
</header>
{/* Hero Section */}
<main className="container mx-auto px-4 pt-32 pb-20">
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-5xl md:text-6xl font-semibold text-text-primary mb-6 leading-tight">
Connect Dental Clinics & Labs
<span className="text-primary"> Seamlessly</span>
</h1>
<p className="text-lg text-text-secondary mb-8 max-w-2xl mx-auto">
Streamline communication between dental professionals. Start with
a 30-day free trial, no credit card required.
</p>
{!user && (
<Link href="/register">
<Button size="lg" variant="primary" className="px-8">
Start Free Trial
</Button>
</Link>
)}
</div>
{/* Features */}
<div className="mt-20 grid md:grid-cols-3 gap-6">
<FeatureCard
icon={<Building2 className="h-6 w-6" />}
title="For Clinics"
description="Manage patients, appointments, and send cases to labs instantly."
/>
<FeatureCard
icon={<Beaker className="h-6 w-6" />}
title="For Labs"
description="Receive cases, track progress, and communicate with clinics."
/>
<FeatureCard
icon={<Users className="h-6 w-6" />}
title="Team Management"
description="Add up to 5 team members during trial. Scale as you grow."
/>
<FeatureCard
icon={<Calendar className="h-6 w-6" />}
title="30-Day Trial"
description="Full access to all features. No credit card required."
/>
<FeatureCard
icon={<Clock className="h-6 w-6" />}
title="Real-time Updates"
description="Get instant notifications on case status changes."
/>
<FeatureCard
icon={<Shield className="h-6 w-6" />}
title="Secure & Compliant"
description="HIPAA-compliant with enterprise-grade security."
/>
</div>
</main>
{/* Footer */}
<footer className="border-t border-border bg-background-secondary">
<div className="container mx-auto px-4 py-8 flex flex-col md:flex-row justify-between items-center text-sm text-text-secondary">
<div>© 2026 DyoLink. All rights reserved.</div>
<div className="flex gap-6 mt-4 md:mt-0">
<Link href="/terms" className="hover:text-primary transition-colors">
Terms & Conditions
</Link>
<Link href="/privacy" className="hover:text-primary transition-colors">
Privacy Policy
</Link>
</div>
</div>
</footer>
</div>
);
}
function FeatureCard({
icon,
title,
description,
}: {
icon: React.ReactNode;
title: string;
description: string;
}) {
return (
<div className="bg-background-card border border-border rounded-2xl p-5 transition-all hover:border-primary hover:shadow-[0_0_20px_rgba(0,194,255,0.15)]">
<div className="text-primary mb-4">
{icon}
</div>
<h3 className="text-base font-medium text-text-primary mb-2">
{title}
</h3>
<p className="text-sm text-text-secondary">
{description}
</p>
</div>
);
}

View File

@@ -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<typeof registerSchema>;
export default function RegisterPage() {
const { registerTrial, isLoading } = useAuth();
const router = useRouter();
const [step, setStep] = useState(1);
const [error, setError] = useState<string | null>(null);
const {
register,
handleSubmit,
watch,
formState: { errors },
trigger,
setValue,
} = useForm<RegisterForm>({
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 (
<div className="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
<Link href="/" className="flex justify-center">
<span className="text-3xl font-bold text-primary-600">DyoLink</span>
</Link>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
Start your 30-day free trial
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
Already have an account?{' '}
<Link href="/login" className="font-medium text-primary-600 hover:text-primary-500">
Sign in
</Link>
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{/* Progress Steps */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step >= 1 ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-600'}`}>
1
</div>
<div className={`ml-2 text-sm font-medium ${step >= 1 ? 'text-primary-600' : 'text-gray-500'
}`}>
Account
</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400" />
<div className="flex items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${step >= 2 ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-600'}`}>
2
</div>
<div className={`ml-2 text-sm font-medium ${step >= 2 ? 'text-primary-600' : 'text-gray-500'
}`}>
Organization
</div>
</div>
</div>
</div>
{/* Trial Info Banner */}
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-100">
<h3 className="text-sm font-medium text-blue-800 mb-2">Your trial
includes:</h3>
<ul className="text-sm text-blue-700 space-y-1">
<li className="flex items-center">
<span className="mr-2"></span> Up to 5 team members
</li>
<li className="flex items-center">
<span className="mr-2"></span> Full access to all features
</li>
<li className="flex items-center">
<span className="mr-2"></span> 30 days free, no credit card
required
</li>
</ul>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{step === 1 && (
<>
<Input
label="Full name"
{...register('name')}
placeholder="John Doe"
error={errors.name?.message}
icon={<User className="h-5 w-5 text-gray-400" />}
/>
<Input
label="Email address"
{...register('email')}
type="email"
placeholder="you@example.com"
error={errors.email?.message}
icon={<Mail className="h-5 w-5 text-gray-400" />}
/>
<Input
label="Password"
{...register('password')}
type="password"
placeholder="••••••••"
error={errors.password?.message}
icon={<Lock className="h-5 w-5 text-gray-400" />}
/>
<Input
label="Confirm password"
{...register('confirmPassword')}
type="password"
placeholder="••••••••"
error={errors.confirmPassword?.message}
icon={<Lock className="h-5 w-5 text-gray-400" />}
/>
<Button
type="button"
variant="primary"
onClick={handleNext}
fullWidth
>
Continue
</Button>
</>
)}
{step === 2 && (
<>
<Input
label="Organization name"
{...register('organizationName')}
placeholder="Sunshine Dental Clinic"
error={errors.organizationName?.message}
icon={<Building2 className="h-5 w-5 text-gray-400" />}
/>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Organization type
</label>
<input type="hidden" {...register('organizationType')} />
<div className="grid grid-cols-2 gap-4">
<button
type="button"
onClick={() => {
setValue('organizationType', 'CLINIC', { shouldValidate: true });
}}
className={`p-4 border rounded-lg text-center transition-colors ${organizationType === 'CLINIC' ? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<Building2 className="h-8 w-8 mx-auto mb-2" />
<span className="text-sm font-medium">Dental Clinic</span>
</button>
<button
type="button"
onClick={() => {
setValue('organizationType', 'LAB', { shouldValidate: true });
}}
className={`p-4 border rounded-lg text-center transition-colors ${organizationType === 'LAB'
? 'border-primary-600 bg-primary-50 text-primary-700'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<Building2 className="h-8 w-8 mx-auto mb-2" />
<span className="text-sm font-medium">Dental Lab</span>
</button>
</div>
{errors.organizationType && (
<p className="mt-2 text-sm text-red-600">{errors.organizationType.message}</p>
)}
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => setStep(1)}
>
Back
</Button>
<Button
type="submit"
variant="primary"
isLoading={isLoading}
fullWidth
>
Start my free trial
</Button>
</div>
</>
)}
</form>
<p className="mt-6 text-xs text-center text-gray-500">
By signing up, you agree to our{' '}
<Link href="/terms" className="text-primary-600 hover:text-primary-500">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary-600 hover:text-primary-500">
Privacy Policy
</Link>
</p>
</div>
</div>
</div>
);
}

View File

@@ -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'
? <Building2 className="h-8 w-8" />
: <Beaker className="h-8 w-8" />;
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-text-secondary">Loading organizations...</p>
</div>
);
}
if (!organizations.length) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-text-secondary">No organizations found.</p>
</div>
);
}
return (
<div className="min-h-screen bg-background-secondary flex items-center justify-center p-4">
<div className="max-w-2xl w-full">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-text-primary">
Choose Organization
</h1>
<p className="text-text-secondary mt-2">
You have access to multiple organizations. Select one to continue.
</p>
</div>
<div className="grid gap-4">
{organizations.map((org) => (
<button
key={org.id}
onClick={() => selectOrganization(org.id)}
className="bg-white p-6 rounded-xl shadow-sm border border-border hover:border-primary-300 hover:shadow-md transition-all text-left flex items-center gap-4"
>
<div className="p-3 bg-primary-50 rounded-lg text-primary-600">
{getIcon(org.type)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">
{org.name}
</h3>
<p className="text-sm text-text-secondary">
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
</p>
</div>
<div className="text-primary-600 text-sm">
Continue
</div>
</button>
))}
</div>
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -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 (
<html lang="en">
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}

View File

@@ -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<BadgeVariant, string> = {
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 (
<span
className={`px-2 py-1 text-xs font-medium rounded-lg border inline-block ${variantStyles[variant]} ${className || ''}`}
>
{children}
</span>
);
}

View File

@@ -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<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
isLoading?: boolean;
fullWidth?: boolean;
children: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
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<ButtonVariant, string> = {
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<ButtonSize, string> = {
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 (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${className}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
)}
{children}
</button>
);
};

View File

@@ -0,0 +1,67 @@
// src/components/ui/Input.tsx
import React, { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
icon?: React.ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, icon, className = '', id, ...props }, ref) => {
const inputId =
id || `input-${Math.random().toString(36).slice(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-text-secondary mb-1"
>
{label}
</label>
)}
<div className="relative">
{icon && (
<div className="absolute inset-y-0 left-0 pl-3 flex items-center text-text-secondary pointer-events-none">
{icon}
</div>
)}
<input
ref={ref}
id={inputId}
className={`
w-full rounded-lg border
${error ? 'border-red-500' : 'border-border'}
bg-background-secondary text-text-primary
${icon ? 'pl-10' : 'pl-4'} pr-4 py-2
placeholder:text-text-secondary
focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
${className}
`}
{...props}
/>
</div>
{error && (
<p className="mt-1 text-sm text-red-500">
{error}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -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<OrganizationCardProps> = ({
organization,
onSelect,
}) => {
const Icon = organization.type === 'CLINIC' ? Building2 : Beaker;
const typeText = organization.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab';
return (
<button
onClick={() => onSelect(organization.id)}
className="w-full bg-white p-6 rounded-xl shadow-sm border border-gray-200 hover:border-primary-300 hover:shadow-md transition-all text-left flex items-center gap-4 group"
>
<div className="p-3 bg-primary-50 rounded-lg text-primary-600">
<Icon className="h-8 w-8" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">{organization.name}</h3>
<p className="text-sm text-gray-500">{typeText}</p>
{organization.plan && (
<p className="text-xs text-gray-400 mt-1">
Plan: {organization.plan.name} {organization.plan.maxUsers} users
</p>
)}
</div>
<ChevronRight className="h-5 w-5 text-gray-400 group-hover:text-primary-600 transition-colors" />
</button>
);
};

View File

@@ -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 (
<aside className="w-64 bg-[#071a2f] text-white flex flex-col p-4">
<div className="mb-8">
<h1 className="text-xl font-bold">DyoLink</h1>
</div>
<nav className="flex flex-col gap-2">
{menu.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.path;
return (
<button
key={item.name}
onClick={() => router.push(item.path)}
className={`flex items-center gap-3 p-3 rounded-lg transition ${
isActive
? 'bg-primary-600'
: 'hover:bg-white/10'
}`}
>
<Icon className="w-5 h-5" />
<span>{item.name}</span>
</button>
);
})}
</nav>
</aside>
);
}

View File

@@ -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<AuthResponse> => {
const response = await apiClient.post('/auth/register', data);
return response.data;
},
// Login user
login: async (data: LoginData): Promise<AuthResponse> => {
const response = await apiClient.post('/auth/login', data);
return response.data;
},
// Get user profile
getProfile: async (): Promise<AuthResponse> => {
const response = await apiClient.get('/auth/profile');
return response.data;
},
// Select organization
selectOrganization: async (organizationId: string): Promise<any> => {
const response = await apiClient.post('/auth/select-organization', { organizationId });
return response.data;
},
// Logout
logout: async (): Promise<void> => {
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;
},
};

View File

@@ -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);
}
);

View File

@@ -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<void>;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
selectOrganization: (orgId: string) => Promise<void>;
clearError: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [currentOrganization, setCurrentOrganization] = useState<Organization | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isAuthReady, setIsAuthReady] = useState(false); // ✅ KEY FIX
const [error, setError] = useState<string | null>(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 (
<AuthContext.Provider
value={{
user,
organizations,
currentOrganization,
isLoading,
isAuthReady, // ✅ expose it
error,
registerTrial,
login,
logout,
selectOrganization,
clearError,
}}
>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
};

View File

@@ -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)$).*)',
],
};

View File

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

View File

@@ -0,0 +1,15 @@
export const colors = {
primary: {
DEFAULT: '#00C2FF',
},
background: {
primary: '#0B1A2B',
secondary: '#0F2236',
card: '#132A42',
},
text: {
primary: '#FFFFFF',
secondary: '#A0AEC0',
},
border: '#1F3A5F',
};

View File

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

View File

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

42
frontend/tsconfig.json Normal file
View File

@@ -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"
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;"]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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