From 9f99b807849c353bc5f067ad75a573dc58b1fe96 Mon Sep 17 00:00:00 2001 From: Avnyr Date: Thu, 15 May 2025 17:09:36 +0200 Subject: [PATCH] feat: implement authentication and database modules with relations and group management Added new authentication strategies (JWT and GitHub OAuth), guards, and controllers. Implemented database module, schema with relations, and group management features, including CRD operations and person-to-group associations. Integrated validation and CORS configuration. --- backend/.gitignore | 56 ++++++ backend/Dockerfile | 36 ++++ backend/README.md | 136 ++++++++++++++ backend/drizzle.config.ts | 26 +++ backend/nest-cli.json | 8 + backend/package.json | 97 ++++++++++ backend/src/app.controller.spec.ts | 22 +++ backend/src/app.controller.ts | 12 ++ backend/src/app.module.ts | 28 +++ backend/src/app.service.ts | 8 + backend/src/database/database.module.ts | 33 ++++ backend/src/database/database.service.ts | 97 ++++++++++ .../migrations/generate-migrations.ts | 55 ++++++ backend/src/database/migrations/migrate.ts | 91 ++++++++++ backend/src/database/schema/db-schema.ts | 17 ++ backend/src/database/schema/enums.ts | 16 ++ backend/src/database/schema/groups.ts | 25 +++ backend/src/database/schema/index.ts | 23 +++ backend/src/database/schema/personToGroup.ts | 25 +++ backend/src/database/schema/personToTag.ts | 25 +++ backend/src/database/schema/persons.ts | 35 ++++ backend/src/database/schema/projectToTag.ts | 25 +++ backend/src/database/schema/projects.ts | 27 +++ backend/src/database/schema/relations.ts | 102 +++++++++++ backend/src/database/schema/tags.ts | 25 +++ backend/src/database/schema/users.ts | 27 +++ backend/src/main.ts | 33 ++++ backend/src/modules/auth/auth.module.ts | 37 ++++ .../auth/controllers/auth.controller.ts | 78 ++++++++ .../auth/decorators/get-user.decorator.ts | 18 ++ .../src/modules/auth/dto/refresh-token.dto.ts | 13 ++ .../modules/auth/guards/github-auth.guard.ts | 8 + .../src/modules/auth/guards/jwt-auth.guard.ts | 18 ++ .../modules/auth/guards/jwt-refresh.guard.ts | 18 ++ .../auth/interfaces/jwt-payload.interface.ts | 24 +++ .../interfaces/tokens-response.interface.ts | 14 ++ .../src/modules/auth/services/auth.service.ts | 96 ++++++++++ .../auth/strategies/github.strategy.ts | 50 ++++++ .../auth/strategies/jwt-refresh.strategy.ts | 51 ++++++ .../modules/auth/strategies/jwt.strategy.ts | 38 ++++ .../groups/controllers/groups.controller.ts | 94 ++++++++++ .../modules/groups/dto/create-group.dto.ts | 27 +++ .../modules/groups/dto/update-group.dto.ts | 27 +++ backend/src/modules/groups/groups.module.ts | 10 ++ .../modules/groups/services/groups.service.ts | 167 ++++++++++++++++++ .../persons/controllers/persons.controller.ts | 94 ++++++++++ .../modules/persons/dto/create-person.dto.ts | 83 +++++++++ .../modules/persons/dto/update-person.dto.ts | 68 +++++++ .../persons/services/persons.service.ts | 145 +++++++++++++++ .../controllers/projects.controller.ts | 73 ++++++++ .../projects/dto/create-project.dto.ts | 22 +++ .../projects/dto/update-project.dto.ts | 22 +++ .../src/modules/projects/projects.module.ts | 10 ++ .../projects/services/projects.service.ts | 108 +++++++++++ .../users/controllers/users.controller.ts | 77 ++++++++ .../src/modules/users/dto/create-user.dto.ts | 22 +++ .../src/modules/users/dto/update-user.dto.ts | 28 +++ .../modules/users/services/users.service.ts | 119 +++++++++++++ backend/src/modules/users/users.module.ts | 10 ++ backend/test/app.e2e-spec.ts | 25 +++ backend/test/jest-e2e.json | 9 + backend/tsconfig.build.json | 4 + backend/tsconfig.json | 21 +++ 63 files changed, 2838 insertions(+) create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/drizzle.config.ts create mode 100644 backend/nest-cli.json create mode 100644 backend/package.json create mode 100644 backend/src/app.controller.spec.ts create mode 100644 backend/src/app.controller.ts create mode 100644 backend/src/app.module.ts create mode 100644 backend/src/app.service.ts create mode 100644 backend/src/database/database.module.ts create mode 100644 backend/src/database/database.service.ts create mode 100644 backend/src/database/migrations/generate-migrations.ts create mode 100644 backend/src/database/migrations/migrate.ts create mode 100644 backend/src/database/schema/db-schema.ts create mode 100644 backend/src/database/schema/enums.ts create mode 100644 backend/src/database/schema/groups.ts create mode 100644 backend/src/database/schema/index.ts create mode 100644 backend/src/database/schema/personToGroup.ts create mode 100644 backend/src/database/schema/personToTag.ts create mode 100644 backend/src/database/schema/persons.ts create mode 100644 backend/src/database/schema/projectToTag.ts create mode 100644 backend/src/database/schema/projects.ts create mode 100644 backend/src/database/schema/relations.ts create mode 100644 backend/src/database/schema/tags.ts create mode 100644 backend/src/database/schema/users.ts create mode 100644 backend/src/main.ts create mode 100644 backend/src/modules/auth/auth.module.ts create mode 100644 backend/src/modules/auth/controllers/auth.controller.ts create mode 100644 backend/src/modules/auth/decorators/get-user.decorator.ts create mode 100644 backend/src/modules/auth/dto/refresh-token.dto.ts create mode 100644 backend/src/modules/auth/guards/github-auth.guard.ts create mode 100644 backend/src/modules/auth/guards/jwt-auth.guard.ts create mode 100644 backend/src/modules/auth/guards/jwt-refresh.guard.ts create mode 100644 backend/src/modules/auth/interfaces/jwt-payload.interface.ts create mode 100644 backend/src/modules/auth/interfaces/tokens-response.interface.ts create mode 100644 backend/src/modules/auth/services/auth.service.ts create mode 100644 backend/src/modules/auth/strategies/github.strategy.ts create mode 100644 backend/src/modules/auth/strategies/jwt-refresh.strategy.ts create mode 100644 backend/src/modules/auth/strategies/jwt.strategy.ts create mode 100644 backend/src/modules/groups/controllers/groups.controller.ts create mode 100644 backend/src/modules/groups/dto/create-group.dto.ts create mode 100644 backend/src/modules/groups/dto/update-group.dto.ts create mode 100644 backend/src/modules/groups/groups.module.ts create mode 100644 backend/src/modules/groups/services/groups.service.ts create mode 100644 backend/src/modules/persons/controllers/persons.controller.ts create mode 100644 backend/src/modules/persons/dto/create-person.dto.ts create mode 100644 backend/src/modules/persons/dto/update-person.dto.ts create mode 100644 backend/src/modules/persons/services/persons.service.ts create mode 100644 backend/src/modules/projects/controllers/projects.controller.ts create mode 100644 backend/src/modules/projects/dto/create-project.dto.ts create mode 100644 backend/src/modules/projects/dto/update-project.dto.ts create mode 100644 backend/src/modules/projects/projects.module.ts create mode 100644 backend/src/modules/projects/services/projects.service.ts create mode 100644 backend/src/modules/users/controllers/users.controller.ts create mode 100644 backend/src/modules/users/dto/create-user.dto.ts create mode 100644 backend/src/modules/users/dto/update-user.dto.ts create mode 100644 backend/src/modules/users/services/users.service.ts create mode 100644 backend/src/modules/users/users.module.ts create mode 100644 backend/test/app.e2e-spec.ts create mode 100644 backend/test/jest-e2e.json create mode 100644 backend/tsconfig.build.json create mode 100644 backend/tsconfig.json diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..4b56acf --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,56 @@ +# compiled output +/dist +/node_modules +/build + +# Logs +logs +*.log +npm-debug.log* +pnpm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# temp directory +.temp +.tmp + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..9b77df0 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,36 @@ +FROM node:20-alpine AS base + +# Install pnpm +RUN npm install -g pnpm + +# Set working directory +WORKDIR /app + +# Copy package.json and install dependencies +FROM base AS dependencies +COPY package.json ./ +RUN pnpm install + +# Build the application +FROM dependencies AS build +COPY . . +RUN pnpm run build + +# Production image +FROM node:20-alpine AS production +WORKDIR /app + +# Copy built application +COPY --from=build /app/dist ./dist +COPY --from=build /app/node_modules ./node_modules +COPY package.json ./ + +# Set environment variables +ENV NODE_ENV=production +ENV PORT=3000 + +# Expose port +EXPOSE ${PORT} + +# Start the application +CMD ["node", "dist/main"] \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..6a320df --- /dev/null +++ b/backend/README.md @@ -0,0 +1,136 @@ +

+ Nest Logo +

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

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

+

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

+ + +## Description + +[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. + +## Project setup + +```bash +$ pnpm install +``` + +### Environment Variables + +The application uses environment variables for configuration. Create a `.env` file in the root directory with the following variables: + +``` +# Database configuration +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=bypass +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres + +# Node environment +NODE_ENV=development + +# Application port +PORT=3000 +``` + +These variables are used by: +- `database.service.ts` - For connecting to the database +- `drizzle.config.ts` - For database migrations +- `main.ts` - For setting the application port + +When running in Docker, these variables are set in the `docker-compose.yml` file. + +## Database Management + +The application uses Drizzle ORM for database management. The following scripts are available: + +```bash +# Generate database migrations +$ pnpm run db:generate + +# Run database migrations +$ pnpm run db:migrate +``` + +## Compile and run the project + +```bash +# development +$ pnpm run start + +# watch mode +$ pnpm run start:dev + +# production mode +$ pnpm run start:prod +``` + +## Run tests + +```bash +# unit tests +$ pnpm run test + +# e2e tests +$ pnpm run test:e2e + +# test coverage +$ pnpm 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 +$ pnpm install -g @nestjs/mau +$ mau deploy +``` + +With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure. + +## Resources + +Check out a few resources that may come in handy when working with NestJS: + +- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework. +- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy). +- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/). +- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks. +- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com). +- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com). +- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs). +- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com). + +## Support + +Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). + +## Stay in touch + +- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec) +- Website - [https://nestjs.com](https://nestjs.com/) +- Twitter - [@nestframework](https://twitter.com/nestframework) + +## License + +Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE). diff --git a/backend/drizzle.config.ts b/backend/drizzle.config.ts new file mode 100644 index 0000000..46d9729 --- /dev/null +++ b/backend/drizzle.config.ts @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2025 Yidhra Studio. - All Rights Reserved + * Updated : 25/04/2025 10:33 + * + * Unauthorized copying or redistribution of this file in source and binary forms via any medium + * is strictly prohibited. + */ + +import { defineConfig } from 'drizzle-kit'; +import * as process from "node:process"; + +export default defineConfig({ + schema: './src/database/schema/index.ts', + out: './src/database/migrations', + dialect: "postgresql", + dbCredentials: { + host: String(process.env.POSTGRES_HOST || "localhost"), + port: Number(process.env.POSTGRES_PORT || 5432), + database: String(process.env.POSTGRES_DB || "groupmaker"), + user: String(process.env.POSTGRES_USER || "postgres"), + password: String(process.env.POSTGRES_PASSWORD || ""), + ssl: false, + }, + verbose: true, + strict: true, +}); diff --git a/backend/nest-cli.json b/backend/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/backend/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..c9cba0f --- /dev/null +++ b/backend/package.json @@ -0,0 +1,97 @@ +{ + "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", + "db:generate": "drizzle-kit generate:pg", + "db:migrate": "ts-node src/database/migrations/migrate.ts", + "db:generate:ts": "ts-node src/database/migrations/generate-migrations.ts", + "db:studio": "drizzle-kit studio", + "db:push": "drizzle-kit push:pg" + }, + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^3.2.0", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.0", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.1", + "@nestjs/websockets": "^11.1.1", + "@node-rs/argon2": "^2.0.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "dotenv": "^16.5.0", + "drizzle-orm": "^0.30.4", + "jose": "^6.0.11", + "passport": "^0.7.0", + "passport-github2": "^0.1.12", + "passport-jwt": "^4.0.1", + "pg": "^8.16.0", + "postgres": "^3.4.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "socket.io": "^4.8.1", + "uuid": "^11.1.0", + "zod": "^3.24.4", + "zod-validation-error": "^3.4.1" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@swc/cli": "^0.6.0", + "@swc/core": "^1.10.7", + "@types/express": "^5.0.0", + "@types/jest": "^29.5.14", + "@types/node": "^22.10.7", + "@types/passport-github2": "^1.2.9", + "@types/pg": "^8.15.1", + "@types/socket.io": "^3.0.2", + "@types/supertest": "^6.0.2", + "@types/uuid": "^10.0.0", + "drizzle-kit": "^0.21.1", + "globals": "^16.0.0", + "jest": "^29.7.0", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/backend/src/app.controller.spec.ts b/backend/src/app.controller.spec.ts new file mode 100644 index 0000000..d22f389 --- /dev/null +++ b/backend/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/backend/src/app.controller.ts b/backend/src/app.controller.ts new file mode 100644 index 0000000..cce879e --- /dev/null +++ b/backend/src/app.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getHello(): string { + return this.appService.getHello(); + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts new file mode 100644 index 0000000..433e6a0 --- /dev/null +++ b/backend/src/app.module.ts @@ -0,0 +1,28 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { DatabaseModule } from './database/database.module'; +import { UsersModule } from './modules/users/users.module'; +import { ProjectsModule } from './modules/projects/projects.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { GroupsModule } from './modules/groups/groups.module'; +import { TagsModule } from './modules/tags/tags.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: '.env', + }), + DatabaseModule, + UsersModule, + ProjectsModule, + AuthModule, + GroupsModule, + TagsModule, + ], + controllers: [AppController], + providers: [AppService], +}) +export class AppModule {} diff --git a/backend/src/app.service.ts b/backend/src/app.service.ts new file mode 100644 index 0000000..927d7cc --- /dev/null +++ b/backend/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/backend/src/database/database.module.ts b/backend/src/database/database.module.ts new file mode 100644 index 0000000..3069189 --- /dev/null +++ b/backend/src/database/database.module.ts @@ -0,0 +1,33 @@ +import { Module, Global } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Pool } from 'pg'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import * as schema from './schema'; +import { DatabaseService } from './database.service'; + +export const DATABASE_POOL = 'DATABASE_POOL'; +export const DRIZZLE = 'DRIZZLE'; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [ + DatabaseService, + { + provide: DATABASE_POOL, + useFactory: (databaseService: DatabaseService) => { + return databaseService.getPool(); + }, + inject: [DatabaseService], + }, + { + provide: DRIZZLE, + useFactory: (databaseService: DatabaseService) => { + return databaseService.getDb(); + }, + inject: [DatabaseService], + }, + ], + exports: [DatabaseService, DATABASE_POOL, DRIZZLE], +}) +export class DatabaseModule {} diff --git a/backend/src/database/database.service.ts b/backend/src/database/database.service.ts new file mode 100644 index 0000000..be6fb68 --- /dev/null +++ b/backend/src/database/database.service.ts @@ -0,0 +1,97 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Pool } from 'pg'; +import * as schema from './schema'; +import { runMigrations } from './migrations/migrate'; + +@Injectable() +export class DatabaseService implements OnModuleInit, OnModuleDestroy { + private readonly pool: Pool; + private readonly db: ReturnType; + + constructor(private configService: ConfigService) { + // Create the PostgreSQL pool + const connectionString = this.getDatabaseConnectionString(); + this.pool = new Pool({ + connectionString, + }); + + // Create the Drizzle ORM instance + this.db = drizzle(this.pool, { schema }); + } + + async onModuleInit() { + // Log database connection + console.log('Connecting to database...'); + + // Test the connection + try { + const client = await this.pool.connect(); + try { + await client.query('SELECT NOW()'); + console.log('Database connection established successfully'); + } finally { + client.release(); + } + } catch (error) { + console.error('Failed to connect to database:', error.message); + throw error; + } + + // Run migrations in all environments + const result = await runMigrations({ migrationsFolder: './src/database/migrations' }); + + // In production, we want to fail if migrations fail + if (!result.success && this.configService.get('NODE_ENV') === 'production') { + throw result.error; + } + } + + async onModuleDestroy() { + // Close the database connection + await this.pool.end(); + console.log('Database connection closed'); + } + + // Get the database connection string from environment variables + private getDatabaseConnectionString(): string { + // First try to get the full DATABASE_URL + const databaseUrl = this.configService.get('DATABASE_URL'); + if (databaseUrl) { + return databaseUrl; + } + + // If DATABASE_URL is not provided, construct it from individual variables + const password = this.configService.get('POSTGRES_PASSWORD'); + const username = this.configService.get('POSTGRES_USER'); + const host = this.configService.get('POSTGRES_HOST'); + const port = this.configService.get('POSTGRES_PORT'); + const database = this.configService.get('POSTGRES_DB'); + + const missingVars: string[] = []; + if (!password) missingVars.push('POSTGRES_PASSWORD'); + if (!username) missingVars.push('POSTGRES_USER'); + if (!host) missingVars.push('POSTGRES_HOST'); + if (!port) missingVars.push('POSTGRES_PORT'); + if (!database) missingVars.push('POSTGRES_DB'); + + if (missingVars.length > 0) { + throw new Error( + `Database configuration is missing. Missing variables: ${missingVars.join(', ')}. Please check your .env file.`, + ); + } + + return `postgres://${username}:${password}@${host}:${port}/${database}`; + } + + // Get the Drizzle ORM instance + getDb() { + return this.db; + } + + // Get the PostgreSQL pool + getPool() { + return this.pool; + } +} diff --git a/backend/src/database/migrations/generate-migrations.ts b/backend/src/database/migrations/generate-migrations.ts new file mode 100644 index 0000000..f18112c --- /dev/null +++ b/backend/src/database/migrations/generate-migrations.ts @@ -0,0 +1,55 @@ +import { exec } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * Script to generate migrations using drizzle-kit + * + * This script: + * 1. Runs drizzle-kit generate to create migration files + * 2. Ensures the migrations directory exists + * 3. Handles errors and provides feedback + */ +const main = async () => { + console.log('Generating migrations...'); + + // Ensure migrations directory exists + const migrationsDir = path.join(__dirname); + if (!fs.existsSync(migrationsDir)) { + fs.mkdirSync(migrationsDir, { recursive: true }); + } + + // Run drizzle-kit generate command + const command = 'npx drizzle-kit generate:pg'; + + exec(command, (error, stdout, stderr) => { + if (error) { + console.error(`Error generating migrations: ${error.message}`); + return; + } + + if (stderr) { + console.error(`Migration generation stderr: ${stderr}`); + } + + console.log(stdout); + console.log('Migrations generated successfully'); + + // List generated migration files + const files = fs.readdirSync(migrationsDir) + .filter(file => file.endsWith('.sql')); + + if (files.length === 0) { + console.log('No migration files were generated. Your schema might be up to date.'); + } else { + console.log('Generated migration files:'); + files.forEach(file => console.log(`- ${file}`)); + } + }); +}; + +main().catch(err => { + console.error('Migration generation failed'); + console.error(err); + process.exit(1); +}); \ No newline at end of file diff --git a/backend/src/database/migrations/migrate.ts b/backend/src/database/migrations/migrate.ts new file mode 100644 index 0000000..91d804a --- /dev/null +++ b/backend/src/database/migrations/migrate.ts @@ -0,0 +1,91 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import { Pool } from 'pg'; +import * as dotenv from 'dotenv'; +import * as path from 'path'; +import * as schema from '../schema'; + +/** + * Script to run database migrations + * + * This script: + * 1. Establishes a connection to the PostgreSQL database + * 2. Creates a Drizzle ORM instance + * 3. Runs all pending migrations + * + * It can be used: + * - As a standalone script: `node dist/database/migrations/migrate.js` + * - Integrated with NestJS application lifecycle in database.service.ts + */ + +// Load environment variables +dotenv.config(); + +export const runMigrations = async (options?: { migrationsFolder?: string }) => { + // First try to get the full DATABASE_URL + let connectionString = process.env.DATABASE_URL; + + // If DATABASE_URL is not provided, construct it from individual variables + if (!connectionString) { + const password = process.env.POSTGRES_PASSWORD || 'postgres'; + const username = process.env.POSTGRES_USER || 'postgres'; + const host = process.env.POSTGRES_HOST || 'localhost'; + const port = Number(process.env.POSTGRES_PORT || 5432); + const database = process.env.POSTGRES_DB || 'groupmaker'; + + connectionString = `postgres://${username}:${password}@${host}:${port}/${database}`; + } + + // Create the PostgreSQL pool + const pool = new Pool({ + connectionString, + }); + + // Create the Drizzle ORM instance + const db = drizzle(pool, { schema }); + + console.log('Running migrations...'); + + try { + // Test the connection + const client = await pool.connect(); + try { + await client.query('SELECT NOW()'); + console.log('Database connection established successfully'); + } finally { + client.release(); + } + + // Determine migrations folder path + const migrationsFolder = options?.migrationsFolder || path.join(__dirname); + console.log(`Using migrations folder: ${migrationsFolder}`); + + // Run migrations + await migrate(db, { migrationsFolder }); + + console.log('Migrations completed successfully'); + return { success: true }; + } catch (error) { + console.error('Migration failed'); + console.error(error); + return { success: false, error }; + } finally { + await pool.end(); + console.log('Database connection closed'); + } +}; + +// Run migrations if this script is executed directly +if (require.main === module) { + runMigrations() + .then(result => { + if (!result.success) { + process.exit(1); + } + }) + .catch(err => { + console.error('Migration failed'); + console.error(err); + process.exit(1); + }); +} diff --git a/backend/src/database/schema/db-schema.ts b/backend/src/database/schema/db-schema.ts new file mode 100644 index 0000000..f05fb75 --- /dev/null +++ b/backend/src/database/schema/db-schema.ts @@ -0,0 +1,17 @@ +/* + * Copyright (C) 2025 Yidhra Studio. - All Rights Reserved + * Updated : 25/04/2025 10:33 + * + * Unauthorized copying or redistribution of this file in source and binary forms via any medium + * is strictly prohibited. + */ + +import { pgSchema } from "drizzle-orm/pg-core"; + +/** + * Defines the PostgreSQL schema for the application. + * All database tables are created within this schema namespace. + * The schema name "bypass" is used to isolate the application's tables + * from other applications that might share the same database. + */ +export const DbSchema = pgSchema("groupmaker"); diff --git a/backend/src/database/schema/enums.ts b/backend/src/database/schema/enums.ts new file mode 100644 index 0000000..269cf46 --- /dev/null +++ b/backend/src/database/schema/enums.ts @@ -0,0 +1,16 @@ +import { pgEnum } from 'drizzle-orm/pg-core'; + +/** + * Enum for gender values + */ +export const gender = pgEnum('gender', ['MALE', 'FEMALE', 'NON_BINARY']); + +/** + * Enum for oral ease level values + */ +export const oralEaseLevel = pgEnum('oralEaseLevel', ['SHY', 'RESERVED', 'COMFORTABLE']); + +/** + * Enum for tag types + */ +export const tagType = pgEnum('tagType', ['PROJECT', 'PERSON']); \ No newline at end of file diff --git a/backend/src/database/schema/groups.ts b/backend/src/database/schema/groups.ts new file mode 100644 index 0000000..0f7c161 --- /dev/null +++ b/backend/src/database/schema/groups.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, varchar, timestamp, jsonb, index } from 'drizzle-orm/pg-core'; +import { projects } from './projects'; + +/** + * Groups table schema + */ +export const groups = pgTable('groups', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 100 }).notNull(), + projectId: uuid('projectId').notNull().references(() => projects.id, { onDelete: 'cascade' }), + metadata: jsonb('metadata').default({}), + createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull() +}, (table) => { + return { + nameIdx: index('group_name_idx').on(table.name), + projectIdIdx: index('group_projectId_idx').on(table.projectId) + }; +}); + +/** + * Group type definitions + */ +export type Group = typeof groups.$inferSelect; +export type NewGroup = typeof groups.$inferInsert; diff --git a/backend/src/database/schema/index.ts b/backend/src/database/schema/index.ts new file mode 100644 index 0000000..94b81c0 --- /dev/null +++ b/backend/src/database/schema/index.ts @@ -0,0 +1,23 @@ +/** + * This file serves as the main entry point for the database schema definitions. + * It exports all schema definitions from various modules. + */ + +// Export schema +export * from './db-schema'; + +// Export enums +export * from './enums'; + +// Export tables +export * from './users'; +export * from './projects'; +export * from './persons'; +export * from './groups'; +export * from './tags'; +export * from './personToGroup'; +export * from './personToTag'; +export * from './projectToTag'; + +// Export relations +export * from './relations'; diff --git a/backend/src/database/schema/personToGroup.ts b/backend/src/database/schema/personToGroup.ts new file mode 100644 index 0000000..57eba17 --- /dev/null +++ b/backend/src/database/schema/personToGroup.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core'; +import { persons } from './persons'; +import { groups } from './groups'; + +/** + * Person to Group relation table schema + */ +export const personToGroup = pgTable('person_to_group', { + id: uuid('id').primaryKey().defaultRandom(), + personId: uuid('personId').notNull().references(() => persons.id, { onDelete: 'cascade' }), + groupId: uuid('groupId').notNull().references(() => groups.id, { onDelete: 'cascade' }), + createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull() +}, (table) => { + return { + personIdIdx: index('ptg_personId_idx').on(table.personId), + groupIdIdx: index('ptg_groupId_idx').on(table.groupId), + personGroupUniqueIdx: uniqueIndex('ptg_person_group_unique_idx').on(table.personId, table.groupId) + }; +}); + +/** + * PersonToGroup type definitions + */ +export type PersonToGroup = typeof personToGroup.$inferSelect; +export type NewPersonToGroup = typeof personToGroup.$inferInsert; diff --git a/backend/src/database/schema/personToTag.ts b/backend/src/database/schema/personToTag.ts new file mode 100644 index 0000000..4ef1daa --- /dev/null +++ b/backend/src/database/schema/personToTag.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core'; +import { persons } from './persons'; +import { tags } from './tags'; + +/** + * Person to Tag relation table schema + */ +export const personToTag = pgTable('person_to_tag', { + id: uuid('id').primaryKey().defaultRandom(), + personId: uuid('personId').notNull().references(() => persons.id, { onDelete: 'cascade' }), + tagId: uuid('tagId').notNull().references(() => tags.id, { onDelete: 'cascade' }), + createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull() +}, (table) => { + return { + personIdIdx: index('ptt_personId_idx').on(table.personId), + tagIdIdx: index('ptt_tagId_idx').on(table.tagId), + personTagUniqueIdx: uniqueIndex('ptt_person_tag_unique_idx').on(table.personId, table.tagId) + }; +}); + +/** + * PersonToTag type definitions + */ +export type PersonToTag = typeof personToTag.$inferSelect; +export type NewPersonToTag = typeof personToTag.$inferInsert; diff --git a/backend/src/database/schema/persons.ts b/backend/src/database/schema/persons.ts new file mode 100644 index 0000000..47dd446 --- /dev/null +++ b/backend/src/database/schema/persons.ts @@ -0,0 +1,35 @@ +import { pgTable, uuid, varchar, smallint, boolean, timestamp, jsonb, index } from 'drizzle-orm/pg-core'; +import { projects } from './projects'; +import { gender, oralEaseLevel } from './enums'; + +/** + * Persons table schema + */ +export const persons = pgTable('persons', { + id: uuid('id').primaryKey().defaultRandom(), + firstName: varchar('firstName', { length: 50 }).notNull(), + lastName: varchar('lastName', { length: 50 }).notNull(), + gender: gender('gender').notNull(), + technicalLevel: smallint('technicalLevel').notNull(), + hasTechnicalTraining: boolean('hasTechnicalTraining').notNull().default(false), + frenchSpeakingLevel: smallint('frenchSpeakingLevel').notNull(), + oralEaseLevel: oralEaseLevel('oralEaseLevel').notNull(), + age: smallint('age'), + projectId: uuid('projectId').notNull().references(() => projects.id, { onDelete: 'cascade' }), + attributes: jsonb('attributes').default({}), + createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull() +}, (table) => { + return { + firstNameIdx: index('person_firstName_idx').on(table.firstName), + lastNameIdx: index('person_lastName_idx').on(table.lastName), + projectIdIdx: index('person_projectId_idx').on(table.projectId), + nameCompositeIdx: index('person_name_composite_idx').on(table.firstName, table.lastName) + }; +}); + +/** + * Person type definitions + */ +export type Person = typeof persons.$inferSelect; +export type NewPerson = typeof persons.$inferInsert; diff --git a/backend/src/database/schema/projectToTag.ts b/backend/src/database/schema/projectToTag.ts new file mode 100644 index 0000000..ebb2b2f --- /dev/null +++ b/backend/src/database/schema/projectToTag.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, timestamp, index, uniqueIndex } from 'drizzle-orm/pg-core'; +import { projects } from './projects'; +import { tags } from './tags'; + +/** + * Project to Tag relation table schema + */ +export const projectToTag = pgTable('project_to_tag', { + id: uuid('id').primaryKey().defaultRandom(), + projectId: uuid('projectId').notNull().references(() => projects.id, { onDelete: 'cascade' }), + tagId: uuid('tagId').notNull().references(() => tags.id, { onDelete: 'cascade' }), + createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull() +}, (table) => { + return { + projectIdIdx: index('pjt_projectId_idx').on(table.projectId), + tagIdIdx: index('pjt_tagId_idx').on(table.tagId), + projectTagUniqueIdx: uniqueIndex('pjt_project_tag_unique_idx').on(table.projectId, table.tagId) + }; +}); + +/** + * ProjectToTag type definitions + */ +export type ProjectToTag = typeof projectToTag.$inferSelect; +export type NewProjectToTag = typeof projectToTag.$inferInsert; diff --git a/backend/src/database/schema/projects.ts b/backend/src/database/schema/projects.ts new file mode 100644 index 0000000..010fbce --- /dev/null +++ b/backend/src/database/schema/projects.ts @@ -0,0 +1,27 @@ +import { pgTable, uuid, varchar, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'; +import { users } from './users'; + +/** + * Projects table schema + */ +export const projects = pgTable('projects', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 100 }).notNull(), + description: text('description'), + ownerId: uuid('ownerId').notNull().references(() => users.id, { onDelete: 'cascade' }), + settings: jsonb('settings').default({}), + createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull() +}, (table) => { + return { + nameIdx: index('project_name_idx').on(table.name), + ownerIdIdx: index('project_ownerId_idx').on(table.ownerId), + createdAtIdx: index('project_createdAt_idx').on(table.createdAt) + }; +}); + +/** + * Project type definitions + */ +export type Project = typeof projects.$inferSelect; +export type NewProject = typeof projects.$inferInsert; diff --git a/backend/src/database/schema/relations.ts b/backend/src/database/schema/relations.ts new file mode 100644 index 0000000..223dd23 --- /dev/null +++ b/backend/src/database/schema/relations.ts @@ -0,0 +1,102 @@ +import { relations } from 'drizzle-orm'; +import { users } from './users'; +import { projects } from './projects'; +import { persons } from './persons'; +import { groups } from './groups'; +import { tags } from './tags'; +import { personToGroup } from './personToGroup'; +import { personToTag } from './personToTag'; +import { projectToTag } from './projectToTag'; + +/** + * Define relations for users table + */ +export const usersRelations = relations(users, ({ many }) => ({ + projects: many(projects), +})); + +/** + * Define relations for projects table + */ +export const projectsRelations = relations(projects, ({ one, many }) => ({ + owner: one(users, { + fields: [projects.ownerId], + references: [users.id], + }), + persons: many(persons), + groups: many(groups), + projectToTags: many(projectToTag), +})); + +/** + * Define relations for persons table + */ +export const personsRelations = relations(persons, ({ one, many }) => ({ + project: one(projects, { + fields: [persons.projectId], + references: [projects.id], + }), + personToGroups: many(personToGroup), + personToTags: many(personToTag), +})); + +/** + * Define relations for groups table + */ +export const groupsRelations = relations(groups, ({ one, many }) => ({ + project: one(projects, { + fields: [groups.projectId], + references: [projects.id], + }), + personToGroups: many(personToGroup), +})); + +/** + * Define relations for tags table + */ +export const tagsRelations = relations(tags, ({ many }) => ({ + personToTags: many(personToTag), + projectToTags: many(projectToTag), +})); + +/** + * Define relations for personToGroup table + */ +export const personToGroupRelations = relations(personToGroup, ({ one }) => ({ + person: one(persons, { + fields: [personToGroup.personId], + references: [persons.id], + }), + group: one(groups, { + fields: [personToGroup.groupId], + references: [groups.id], + }), +})); + +/** + * Define relations for personToTag table + */ +export const personToTagRelations = relations(personToTag, ({ one }) => ({ + person: one(persons, { + fields: [personToTag.personId], + references: [persons.id], + }), + tag: one(tags, { + fields: [personToTag.tagId], + references: [tags.id], + }), +})); + +/** + * Define relations for projectToTag table + */ +export const projectToTagRelations = relations(projectToTag, ({ one }) => ({ + project: one(projects, { + fields: [projectToTag.projectId], + references: [projects.id], + }), + tag: one(tags, { + fields: [projectToTag.tagId], + references: [tags.id], + }), +})); \ No newline at end of file diff --git a/backend/src/database/schema/tags.ts b/backend/src/database/schema/tags.ts new file mode 100644 index 0000000..a29cd0e --- /dev/null +++ b/backend/src/database/schema/tags.ts @@ -0,0 +1,25 @@ +import { pgTable, uuid, varchar, timestamp, index } from 'drizzle-orm/pg-core'; +import { tagType } from './enums'; + +/** + * Tags table schema + */ +export const tags = pgTable('tags', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 50 }).notNull(), + color: varchar('color', { length: 7 }).notNull(), + type: tagType('type').notNull(), + createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull() +}, (table) => { + return { + nameIdx: index('tag_name_idx').on(table.name), + typeIdx: index('tag_type_idx').on(table.type) + }; +}); + +/** + * Tag type definitions + */ +export type Tag = typeof tags.$inferSelect; +export type NewTag = typeof tags.$inferInsert; diff --git a/backend/src/database/schema/users.ts b/backend/src/database/schema/users.ts new file mode 100644 index 0000000..154904f --- /dev/null +++ b/backend/src/database/schema/users.ts @@ -0,0 +1,27 @@ +import { pgTable, uuid, varchar, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core'; +import { DbSchema } from './db-schema'; + +/** + * Users table schema + */ +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), // UUIDv7 for chronological order + name: varchar('name', { length: 100 }).notNull(), + avatar: text('avatar'), // URL from Github API + githubId: varchar('githubId', { length: 50 }).notNull().unique(), + gdprTimestamp: timestamp('gdprTimestamp', { withTimezone: true }), + createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updatedAt', { withTimezone: true }).defaultNow().notNull(), + metadata: jsonb('metadata').default({}) +}, (table) => { + return { + githubIdIdx: index('githubId_idx').on(table.githubId), + createdAtIdx: index('createdAt_idx').on(table.createdAt) + }; +}); + +/** + * User type definitions + */ +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; \ No newline at end of file diff --git a/backend/src/main.ts b/backend/src/main.ts new file mode 100644 index 0000000..d28a006 --- /dev/null +++ b/backend/src/main.ts @@ -0,0 +1,33 @@ +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + const configService = app.get(ConfigService); + + // Configuration globale des pipes de validation + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + }), + ); + + // Configuration CORS + app.enableCors({ + origin: configService.get('CORS_ORIGIN', 'http://localhost:3000'), + methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', + credentials: true, + }); + + // Préfixe global pour les routes API + app.setGlobalPrefix(configService.get('API_PREFIX', 'api')); + + const port = configService.get('PORT', 3000); + await app.listen(port); + console.log(`Application is running on: http://localhost:${port}`); +} +bootstrap(); diff --git a/backend/src/modules/auth/auth.module.ts b/backend/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..7588bf4 --- /dev/null +++ b/backend/src/modules/auth/auth.module.ts @@ -0,0 +1,37 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { UsersModule } from '../users/users.module'; +import { AuthController } from './controllers/auth.controller'; +import { AuthService } from './services/auth.service'; +import { GithubStrategy } from './strategies/github.strategy'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; + +@Module({ + imports: [ + ConfigModule, + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => ({ + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: configService.get('JWT_EXPIRATION') || '15m', + }, + }), + }), + UsersModule, + ], + controllers: [AuthController], + providers: [ + AuthService, + GithubStrategy, + JwtStrategy, + JwtRefreshStrategy, + ], + exports: [AuthService, JwtStrategy, JwtRefreshStrategy, PassportModule], +}) +export class AuthModule {} \ No newline at end of file diff --git a/backend/src/modules/auth/controllers/auth.controller.ts b/backend/src/modules/auth/controllers/auth.controller.ts new file mode 100644 index 0000000..c18138d --- /dev/null +++ b/backend/src/modules/auth/controllers/auth.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Get, + Post, + Req, + Res, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Request, Response } from 'express'; +import { AuthService } from '../services/auth.service'; +import { RefreshTokenDto } from '../dto/refresh-token.dto'; +import { GithubAuthGuard } from '../guards/github-auth.guard'; +import { JwtAuthGuard } from '../guards/jwt-auth.guard'; +import { JwtRefreshGuard } from '../guards/jwt-refresh.guard'; +import { GetUser } from '../decorators/get-user.decorator'; + +@Controller('auth') +export class AuthController { + constructor( + private readonly authService: AuthService, + private readonly configService: ConfigService, + ) {} + + /** + * Initiate GitHub OAuth flow + */ + @Get('github') + @UseGuards(GithubAuthGuard) + githubAuth() { + // This route is handled by the GitHub strategy + // The actual implementation is in the GithubAuthGuard + } + + /** + * Handle GitHub OAuth callback + */ + @Get('github/callback') + @UseGuards(GithubAuthGuard) + async githubAuthCallback(@Req() req: Request, @Res() res: Response) { + // The user is already validated by the GitHub strategy + // and attached to the request object + const user = req.user as any; + + if (!user) { + throw new UnauthorizedException('Authentication failed'); + } + + // Generate tokens + const tokens = await this.authService.generateTokens(user.id); + + // Redirect to the frontend with tokens + const frontendUrl = this.configService.get('FRONTEND_URL') || 'http://localhost:3000'; + const redirectUrl = `${frontendUrl}/auth/callback?accessToken=${tokens.accessToken}&refreshToken=${tokens.refreshToken}`; + + return res.redirect(redirectUrl); + } + + /** + * Refresh tokens + */ + @Post('refresh') + @UseGuards(JwtRefreshGuard) + async refreshTokens(@GetUser() user) { + return this.authService.refreshTokens(user.id, user.refreshToken); + } + + /** + * Get current user profile + */ + @Get('profile') + @UseGuards(JwtAuthGuard) + getProfile(@GetUser() user) { + return user; + } +} diff --git a/backend/src/modules/auth/decorators/get-user.decorator.ts b/backend/src/modules/auth/decorators/get-user.decorator.ts new file mode 100644 index 0000000..0f5072e --- /dev/null +++ b/backend/src/modules/auth/decorators/get-user.decorator.ts @@ -0,0 +1,18 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; + +/** + * Decorator to extract user information from the request + * + * Usage: + * - @GetUser() user: any - Get the entire user object + * - @GetUser('id') userId: string - Get a specific property from the user object + */ +export const GetUser = createParamDecorator( + (data: string | undefined, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + + // Return the specific property if data is provided, otherwise return the entire user object + return data ? user?.[data] : user; + }, +); \ No newline at end of file diff --git a/backend/src/modules/auth/dto/refresh-token.dto.ts b/backend/src/modules/auth/dto/refresh-token.dto.ts new file mode 100644 index 0000000..a543284 --- /dev/null +++ b/backend/src/modules/auth/dto/refresh-token.dto.ts @@ -0,0 +1,13 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +/** + * DTO for refresh token request + */ +export class RefreshTokenDto { + /** + * The refresh token + */ + @IsNotEmpty() + @IsString() + refreshToken: string; +} \ No newline at end of file diff --git a/backend/src/modules/auth/guards/github-auth.guard.ts b/backend/src/modules/auth/guards/github-auth.guard.ts new file mode 100644 index 0000000..7346c13 --- /dev/null +++ b/backend/src/modules/auth/guards/github-auth.guard.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +/** + * Guard for GitHub OAuth authentication + */ +@Injectable() +export class GithubAuthGuard extends AuthGuard('github') {} \ No newline at end of file diff --git a/backend/src/modules/auth/guards/jwt-auth.guard.ts b/backend/src/modules/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..4df3ddd --- /dev/null +++ b/backend/src/modules/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,18 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +/** + * Guard for JWT authentication + */ +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + /** + * Handle unauthorized errors + */ + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + throw err || new UnauthorizedException('Authentication required'); + } + return user; + } +} \ No newline at end of file diff --git a/backend/src/modules/auth/guards/jwt-refresh.guard.ts b/backend/src/modules/auth/guards/jwt-refresh.guard.ts new file mode 100644 index 0000000..beae755 --- /dev/null +++ b/backend/src/modules/auth/guards/jwt-refresh.guard.ts @@ -0,0 +1,18 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +/** + * Guard for JWT refresh token authentication + */ +@Injectable() +export class JwtRefreshGuard extends AuthGuard('jwt-refresh') { + /** + * Handle unauthorized errors + */ + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + throw err || new UnauthorizedException('Valid refresh token required'); + } + return user; + } +} \ No newline at end of file diff --git a/backend/src/modules/auth/interfaces/jwt-payload.interface.ts b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts new file mode 100644 index 0000000..10bb0d2 --- /dev/null +++ b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts @@ -0,0 +1,24 @@ +/** + * Interface for JWT payload + */ +export interface JwtPayload { + /** + * Subject (user ID) + */ + sub: string; + + /** + * Flag to indicate if this is a refresh token + */ + isRefreshToken?: boolean; + + /** + * Token issued at timestamp + */ + iat?: number; + + /** + * Token expiration timestamp + */ + exp?: number; +} \ No newline at end of file diff --git a/backend/src/modules/auth/interfaces/tokens-response.interface.ts b/backend/src/modules/auth/interfaces/tokens-response.interface.ts new file mode 100644 index 0000000..67da816 --- /dev/null +++ b/backend/src/modules/auth/interfaces/tokens-response.interface.ts @@ -0,0 +1,14 @@ +/** + * Interface for tokens response + */ +export interface TokensResponse { + /** + * JWT access token + */ + accessToken: string; + + /** + * JWT refresh token + */ + refreshToken: string; +} \ No newline at end of file diff --git a/backend/src/modules/auth/services/auth.service.ts b/backend/src/modules/auth/services/auth.service.ts new file mode 100644 index 0000000..adbb3ec --- /dev/null +++ b/backend/src/modules/auth/services/auth.service.ts @@ -0,0 +1,96 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { JwtService } from '@nestjs/jwt'; +import { UsersService } from '../../users/services/users.service'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; +import { TokensResponse } from '../interfaces/tokens-response.interface'; + +@Injectable() +export class AuthService { + constructor( + private readonly usersService: UsersService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + ) {} + + /** + * Validate a user by GitHub ID + */ + async validateGithubUser( + githubId: string, + email: string, + name: string, + avatarUrl: string, + ) { + // Try to find the user by GitHub ID + let user = await this.usersService.findByGithubId(githubId); + + // If user doesn't exist, create a new one + if (!user) { + user = await this.usersService.create({ + githubId, + name, + avatar: avatarUrl, + metadata: { email }, + }); + } + + return user; + } + + /** + * Generate JWT tokens (access and refresh) + */ + async generateTokens(userId: string): Promise { + const payload: JwtPayload = { sub: userId }; + + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync(payload), + this.jwtService.signAsync( + { ...payload, isRefreshToken: true }, + { + expiresIn: this.configService.get('JWT_REFRESH_EXPIRATION') || '7d', + secret: this.configService.get('JWT_REFRESH_SECRET'), + }, + ), + ]); + + return { + accessToken, + refreshToken, + }; + } + + /** + * Refresh tokens using a valid refresh token + */ + async refreshTokens(userId: string, refreshToken: string): Promise { + // Verify the refresh token + try { + const payload = await this.jwtService.verifyAsync(refreshToken, { + secret: this.configService.get('JWT_REFRESH_SECRET'), + }); + + // Check if the token is a refresh token and belongs to the user + if (!payload.isRefreshToken || payload.sub !== userId) { + throw new UnauthorizedException('Invalid refresh token'); + } + + // Generate new tokens + return this.generateTokens(userId); + } catch (error) { + throw new UnauthorizedException('Invalid refresh token'); + } + } + + /** + * Validate a user by JWT payload + */ + async validateJwtUser(payload: JwtPayload) { + const user = await this.usersService.findById(payload.sub); + if (!user) { + throw new UnauthorizedException('User not found'); + } + return user; + } +} diff --git a/backend/src/modules/auth/strategies/github.strategy.ts b/backend/src/modules/auth/strategies/github.strategy.ts new file mode 100644 index 0000000..6d4f15e --- /dev/null +++ b/backend/src/modules/auth/strategies/github.strategy.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy } from 'passport-github2'; +import { AuthService } from '../services/auth.service'; + +@Injectable() +export class GithubStrategy extends PassportStrategy(Strategy, 'github') { + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) { + const clientID = configService.get('GITHUB_CLIENT_ID') || 'dummy-client-id'; + const clientSecret = configService.get('GITHUB_CLIENT_SECRET') || 'dummy-client-secret'; + const callbackURL = configService.get('GITHUB_CALLBACK_URL') || 'http://localhost:3001/api/auth/github/callback'; + + super({ + clientID, + clientSecret, + callbackURL, + scope: ['user:email'], + }); + } + + /** + * Validate the GitHub profile and return the user + */ + async validate(accessToken: string, refreshToken: string, profile: any) { + // Extract user information from GitHub profile + const { id, displayName, emails, photos } = profile; + + // Get primary email or first email + const email = emails && emails.length > 0 + ? (emails.find(e => e.primary)?.value || emails[0].value) + : null; + + // Get avatar URL + const avatarUrl = photos && photos.length > 0 ? photos[0].value : null; + + // Validate or create user + const user = await this.authService.validateGithubUser( + id, + email, + displayName || 'GitHub User', + avatarUrl, + ); + + return user; + } +} diff --git a/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts b/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts new file mode 100644 index 0000000..8958999 --- /dev/null +++ b/backend/src/modules/auth/strategies/jwt-refresh.strategy.ts @@ -0,0 +1,51 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthService } from '../services/auth.service'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; + +@Injectable() +export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_REFRESH_SECRET'), + passReqToCallback: true, + }); + } + + /** + * Validate the JWT refresh token payload and return the user + */ + async validate(req: any, payload: JwtPayload) { + try { + // Check if this is a refresh token + if (!payload.isRefreshToken) { + throw new UnauthorizedException('Invalid token type'); + } + + // Extract the refresh token from the request + const refreshToken = ExtractJwt.fromAuthHeaderAsBearerToken()(req); + + if (!refreshToken) { + throw new UnauthorizedException('Refresh token not found'); + } + + // Validate the user + const user = await this.authService.validateJwtUser(payload); + + // Attach the refresh token to the user object for later use + return { + ...user, + refreshToken, + }; + } catch (error) { + throw new UnauthorizedException('Invalid refresh token'); + } + } +} \ No newline at end of file diff --git a/backend/src/modules/auth/strategies/jwt.strategy.ts b/backend/src/modules/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..04dbd12 --- /dev/null +++ b/backend/src/modules/auth/strategies/jwt.strategy.ts @@ -0,0 +1,38 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthService } from '../services/auth.service'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor( + private readonly configService: ConfigService, + private readonly authService: AuthService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: configService.get('JWT_SECRET'), + }); + } + + /** + * Validate the JWT payload and return the user + */ + async validate(payload: JwtPayload) { + try { + // Check if this is a refresh token + if (payload.isRefreshToken) { + throw new UnauthorizedException('Invalid token type'); + } + + // Validate the user + const user = await this.authService.validateJwtUser(payload); + return user; + } catch (error) { + throw new UnauthorizedException('Invalid token'); + } + } +} \ No newline at end of file diff --git a/backend/src/modules/groups/controllers/groups.controller.ts b/backend/src/modules/groups/controllers/groups.controller.ts new file mode 100644 index 0000000..4cb0a2a --- /dev/null +++ b/backend/src/modules/groups/controllers/groups.controller.ts @@ -0,0 +1,94 @@ +import { + Controller, + Get, + Post, + Body, + Param, + Delete, + Put, + UseGuards, + Query, +} from '@nestjs/common'; +import { GroupsService } from '../services/groups.service'; +import { CreateGroupDto } from '../dto/create-group.dto'; +import { UpdateGroupDto } from '../dto/update-group.dto'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@Controller('groups') +@UseGuards(JwtAuthGuard) +export class GroupsController { + constructor(private readonly groupsService: GroupsService) {} + + /** + * Create a new group + */ + @Post() + create(@Body() createGroupDto: CreateGroupDto) { + return this.groupsService.create(createGroupDto); + } + + /** + * Get all groups or filter by project ID + */ + @Get() + findAll(@Query('projectId') projectId?: string) { + if (projectId) { + return this.groupsService.findByProjectId(projectId); + } + return this.groupsService.findAll(); + } + + /** + * Get a group by ID + */ + @Get(':id') + findOne(@Param('id') id: string) { + return this.groupsService.findById(id); + } + + /** + * Update a group + */ + @Put(':id') + update(@Param('id') id: string, @Body() updateGroupDto: UpdateGroupDto) { + return this.groupsService.update(id, updateGroupDto); + } + + /** + * Delete a group + */ + @Delete(':id') + remove(@Param('id') id: string) { + return this.groupsService.remove(id); + } + + /** + * Add a person to a group + */ + @Post(':id/persons/:personId') + addPersonToGroup( + @Param('id') groupId: string, + @Param('personId') personId: string, + ) { + return this.groupsService.addPersonToGroup(groupId, personId); + } + + /** + * Remove a person from a group + */ + @Delete(':id/persons/:personId') + removePersonFromGroup( + @Param('id') groupId: string, + @Param('personId') personId: string, + ) { + return this.groupsService.removePersonFromGroup(groupId, personId); + } + + /** + * Get all persons in a group + */ + @Get(':id/persons') + getPersonsInGroup(@Param('id') groupId: string) { + return this.groupsService.getPersonsInGroup(groupId); + } +} \ No newline at end of file diff --git a/backend/src/modules/groups/dto/create-group.dto.ts b/backend/src/modules/groups/dto/create-group.dto.ts new file mode 100644 index 0000000..bf7f2f2 --- /dev/null +++ b/backend/src/modules/groups/dto/create-group.dto.ts @@ -0,0 +1,27 @@ +import { IsNotEmpty, IsString, IsUUID, IsObject, IsOptional } from 'class-validator'; + +/** + * DTO for creating a new group + */ +export class CreateGroupDto { + /** + * The name of the group + */ + @IsNotEmpty() + @IsString() + name: string; + + /** + * The ID of the project this group belongs to + */ + @IsNotEmpty() + @IsUUID() + projectId: string; + + /** + * Optional metadata for the group + */ + @IsOptional() + @IsObject() + metadata?: Record; +} \ No newline at end of file diff --git a/backend/src/modules/groups/dto/update-group.dto.ts b/backend/src/modules/groups/dto/update-group.dto.ts new file mode 100644 index 0000000..3ef4deb --- /dev/null +++ b/backend/src/modules/groups/dto/update-group.dto.ts @@ -0,0 +1,27 @@ +import { IsString, IsUUID, IsObject, IsOptional } from 'class-validator'; + +/** + * DTO for updating an existing group + */ +export class UpdateGroupDto { + /** + * The name of the group + */ + @IsOptional() + @IsString() + name?: string; + + /** + * The ID of the project this group belongs to + */ + @IsOptional() + @IsUUID() + projectId?: string; + + /** + * Metadata for the group + */ + @IsOptional() + @IsObject() + metadata?: Record; +} \ No newline at end of file diff --git a/backend/src/modules/groups/groups.module.ts b/backend/src/modules/groups/groups.module.ts new file mode 100644 index 0000000..bb342a9 --- /dev/null +++ b/backend/src/modules/groups/groups.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GroupsController } from './controllers/groups.controller'; +import { GroupsService } from './services/groups.service'; + +@Module({ + controllers: [GroupsController], + providers: [GroupsService], + exports: [GroupsService], +}) +export class GroupsModule {} \ No newline at end of file diff --git a/backend/src/modules/groups/services/groups.service.ts b/backend/src/modules/groups/services/groups.service.ts new file mode 100644 index 0000000..79890f9 --- /dev/null +++ b/backend/src/modules/groups/services/groups.service.ts @@ -0,0 +1,167 @@ +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { DRIZZLE } from '../../../database/database.module'; +import * as schema from '../../../database/schema'; +import { CreateGroupDto } from '../dto/create-group.dto'; +import { UpdateGroupDto } from '../dto/update-group.dto'; + +@Injectable() +export class GroupsService { + constructor(@Inject(DRIZZLE) private readonly db: any) {} + + /** + * Create a new group + */ + async create(createGroupDto: CreateGroupDto) { + const [group] = await this.db + .insert(schema.groups) + .values({ + ...createGroupDto, + }) + .returning(); + return group; + } + + /** + * Find all groups + */ + async findAll() { + return this.db.select().from(schema.groups); + } + + /** + * Find groups by project ID + */ + async findByProjectId(projectId: string) { + return this.db + .select() + .from(schema.groups) + .where(eq(schema.groups.projectId, projectId)); + } + + /** + * Find a group by ID + */ + async findById(id: string) { + const [group] = await this.db + .select() + .from(schema.groups) + .where(eq(schema.groups.id, id)); + + if (!group) { + throw new NotFoundException(`Group with ID ${id} not found`); + } + + return group; + } + + /** + * Update a group + */ + async update(id: string, updateGroupDto: UpdateGroupDto) { + const [group] = await this.db + .update(schema.groups) + .set({ + ...updateGroupDto, + updatedAt: new Date(), + }) + .where(eq(schema.groups.id, id)) + .returning(); + + if (!group) { + throw new NotFoundException(`Group with ID ${id} not found`); + } + + return group; + } + + /** + * Delete a group + */ + async remove(id: string) { + const [group] = await this.db + .delete(schema.groups) + .where(eq(schema.groups.id, id)) + .returning(); + + if (!group) { + throw new NotFoundException(`Group with ID ${id} not found`); + } + + return group; + } + + /** + * Add a person to a group + */ + async addPersonToGroup(groupId: string, personId: string) { + // Check if the group exists + await this.findById(groupId); + + // Check if the person exists + const [person] = await this.db + .select() + .from(schema.persons) + .where(eq(schema.persons.id, personId)); + + if (!person) { + throw new NotFoundException(`Person with ID ${personId} not found`); + } + + // Check if the person is already in the group + const [existingRelation] = await this.db + .select() + .from(schema.personToGroup) + .where(eq(schema.personToGroup.personId, personId)) + .where(eq(schema.personToGroup.groupId, groupId)); + + if (existingRelation) { + return existingRelation; + } + + // Add the person to the group + const [relation] = await this.db + .insert(schema.personToGroup) + .values({ + personId, + groupId, + }) + .returning(); + + return relation; + } + + /** + * Remove a person from a group + */ + async removePersonFromGroup(groupId: string, personId: string) { + const [relation] = await this.db + .delete(schema.personToGroup) + .where(eq(schema.personToGroup.personId, personId)) + .where(eq(schema.personToGroup.groupId, groupId)) + .returning(); + + if (!relation) { + throw new NotFoundException(`Person with ID ${personId} is not in group with ID ${groupId}`); + } + + return relation; + } + + /** + * Get all persons in a group + */ + async getPersonsInGroup(groupId: string) { + // Check if the group exists + await this.findById(groupId); + + // Get all persons in the group + return this.db + .select({ + person: schema.persons, + }) + .from(schema.personToGroup) + .innerJoin(schema.persons, eq(schema.personToGroup.personId, schema.persons.id)) + .where(eq(schema.personToGroup.groupId, groupId)); + } +} \ No newline at end of file diff --git a/backend/src/modules/persons/controllers/persons.controller.ts b/backend/src/modules/persons/controllers/persons.controller.ts new file mode 100644 index 0000000..7da5fa0 --- /dev/null +++ b/backend/src/modules/persons/controllers/persons.controller.ts @@ -0,0 +1,94 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + HttpCode, + HttpStatus, + Query, +} from '@nestjs/common'; +import { PersonsService } from '../services/persons.service'; +import { CreatePersonDto } from '../dto/create-person.dto'; +import { UpdatePersonDto } from '../dto/update-person.dto'; + +@Controller('persons') +export class PersonsController { + constructor(private readonly personsService: PersonsService) {} + + /** + * Create a new person + */ + @Post() + @HttpCode(HttpStatus.CREATED) + create(@Body() createPersonDto: CreatePersonDto) { + return this.personsService.create(createPersonDto); + } + + /** + * Get all persons or filter by project ID + */ + @Get() + findAll(@Query('projectId') projectId?: string) { + if (projectId) { + return this.personsService.findByProjectId(projectId); + } + return this.personsService.findAll(); + } + + /** + * Get a person by ID + */ + @Get(':id') + findOne(@Param('id') id: string) { + return this.personsService.findById(id); + } + + /** + * Update a person + */ + @Patch(':id') + update(@Param('id') id: string, @Body() updatePersonDto: UpdatePersonDto) { + return this.personsService.update(id, updatePersonDto); + } + + /** + * Delete a person + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id') id: string) { + return this.personsService.remove(id); + } + + /** + * Get persons by project ID and group ID + */ + @Get('project/:projectId/group/:groupId') + findByProjectIdAndGroupId( + @Param('projectId') projectId: string, + @Param('groupId') groupId: string, + ) { + return this.personsService.findByProjectIdAndGroupId(projectId, groupId); + } + + /** + * Add a person to a group + */ + @Post(':id/groups/:groupId') + @HttpCode(HttpStatus.CREATED) + addToGroup(@Param('id') id: string, @Param('groupId') groupId: string) { + return this.personsService.addToGroup(id, groupId); + } + + /** + * Remove a person from a group + */ + @Delete(':id/groups/:groupId') + @HttpCode(HttpStatus.NO_CONTENT) + removeFromGroup(@Param('id') id: string, @Param('groupId') groupId: string) { + return this.personsService.removeFromGroup(id, groupId); + } +} \ No newline at end of file diff --git a/backend/src/modules/persons/dto/create-person.dto.ts b/backend/src/modules/persons/dto/create-person.dto.ts new file mode 100644 index 0000000..0ee4257 --- /dev/null +++ b/backend/src/modules/persons/dto/create-person.dto.ts @@ -0,0 +1,83 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsObject, + IsUUID, + IsEnum, + IsInt, + IsBoolean, + Min, + Max +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * Enum for gender values + */ +export enum Gender { + MALE = 'MALE', + FEMALE = 'FEMALE', + NON_BINARY = 'NON_BINARY', +} + +/** + * Enum for oral ease level values + */ +export enum OralEaseLevel { + SHY = 'SHY', + RESERVED = 'RESERVED', + COMFORTABLE = 'COMFORTABLE', +} + +/** + * DTO for creating a new person + */ +export class CreatePersonDto { + @IsString() + @IsNotEmpty() + firstName: string; + + @IsString() + @IsNotEmpty() + lastName: string; + + @IsEnum(Gender) + @IsNotEmpty() + gender: Gender; + + @IsInt() + @Min(1) + @Max(5) + @Type(() => Number) + technicalLevel: number; + + @IsBoolean() + @Type(() => Boolean) + hasTechnicalTraining: boolean; + + @IsInt() + @Min(1) + @Max(5) + @Type(() => Number) + frenchSpeakingLevel: number; + + @IsEnum(OralEaseLevel) + @IsNotEmpty() + oralEaseLevel: OralEaseLevel; + + @IsInt() + @IsOptional() + @Min(18) + @Max(100) + @Type(() => Number) + age?: number; + + @IsUUID() + @IsNotEmpty() + projectId: string; + + @IsObject() + @IsOptional() + attributes?: Record; +} \ No newline at end of file diff --git a/backend/src/modules/persons/dto/update-person.dto.ts b/backend/src/modules/persons/dto/update-person.dto.ts new file mode 100644 index 0000000..b2925ca --- /dev/null +++ b/backend/src/modules/persons/dto/update-person.dto.ts @@ -0,0 +1,68 @@ +import { + IsString, + IsOptional, + IsObject, + IsUUID, + IsEnum, + IsInt, + IsBoolean, + Min, + Max +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { Gender, OralEaseLevel } from './create-person.dto'; + +/** + * DTO for updating a person + */ +export class UpdatePersonDto { + @IsString() + @IsOptional() + firstName?: string; + + @IsString() + @IsOptional() + lastName?: string; + + @IsEnum(Gender) + @IsOptional() + gender?: Gender; + + @IsInt() + @Min(1) + @Max(5) + @IsOptional() + @Type(() => Number) + technicalLevel?: number; + + @IsBoolean() + @IsOptional() + @Type(() => Boolean) + hasTechnicalTraining?: boolean; + + @IsInt() + @Min(1) + @Max(5) + @IsOptional() + @Type(() => Number) + frenchSpeakingLevel?: number; + + @IsEnum(OralEaseLevel) + @IsOptional() + oralEaseLevel?: OralEaseLevel; + + @IsInt() + @IsOptional() + @Min(18) + @Max(100) + @Type(() => Number) + age?: number; + + @IsUUID() + @IsOptional() + projectId?: string; + + @IsObject() + @IsOptional() + attributes?: Record; +} \ No newline at end of file diff --git a/backend/src/modules/persons/services/persons.service.ts b/backend/src/modules/persons/services/persons.service.ts new file mode 100644 index 0000000..2a7323b --- /dev/null +++ b/backend/src/modules/persons/services/persons.service.ts @@ -0,0 +1,145 @@ +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DRIZZLE } from '../../../database/database.module'; +import * as schema from '../../../database/schema'; +import { CreatePersonDto } from '../dto/create-person.dto'; +import { UpdatePersonDto } from '../dto/update-person.dto'; + +@Injectable() +export class PersonsService { + constructor(@Inject(DRIZZLE) private readonly db: any) {} + + /** + * Create a new person + */ + async create(createPersonDto: CreatePersonDto) { + const [person] = await this.db + .insert(schema.persons) + .values(createPersonDto) + .returning(); + return person; + } + + /** + * Find all persons + */ + async findAll() { + return this.db.select().from(schema.persons); + } + + /** + * Find persons by project ID + */ + async findByProjectId(projectId: string) { + return this.db + .select() + .from(schema.persons) + .where(eq(schema.persons.projectId, projectId)); + } + + /** + * Find a person by ID + */ + async findById(id: string) { + const [person] = await this.db + .select() + .from(schema.persons) + .where(eq(schema.persons.id, id)); + + if (!person) { + throw new NotFoundException(`Person with ID ${id} not found`); + } + + return person; + } + + /** + * Update a person + */ + async update(id: string, updatePersonDto: UpdatePersonDto) { + const [person] = await this.db + .update(schema.persons) + .set({ + ...updatePersonDto, + updatedAt: new Date(), + }) + .where(eq(schema.persons.id, id)) + .returning(); + + if (!person) { + throw new NotFoundException(`Person with ID ${id} not found`); + } + + return person; + } + + /** + * Delete a person + */ + async remove(id: string) { + const [person] = await this.db + .delete(schema.persons) + .where(eq(schema.persons.id, id)) + .returning(); + + if (!person) { + throw new NotFoundException(`Person with ID ${id} not found`); + } + + return person; + } + + /** + * Find persons by project ID and group ID + */ + async findByProjectIdAndGroupId(projectId: string, groupId: string) { + return this.db + .select({ + person: schema.persons, + }) + .from(schema.persons) + .innerJoin( + schema.personToGroup, + and( + eq(schema.persons.id, schema.personToGroup.personId), + eq(schema.personToGroup.groupId, groupId) + ) + ) + .where(eq(schema.persons.projectId, projectId)); + } + + /** + * Add a person to a group + */ + async addToGroup(personId: string, groupId: string) { + const [relation] = await this.db + .insert(schema.personToGroup) + .values({ + personId, + groupId, + }) + .returning(); + return relation; + } + + /** + * Remove a person from a group + */ + async removeFromGroup(personId: string, groupId: string) { + const [relation] = await this.db + .delete(schema.personToGroup) + .where( + and( + eq(schema.personToGroup.personId, personId), + eq(schema.personToGroup.groupId, groupId) + ) + ) + .returning(); + + if (!relation) { + throw new NotFoundException(`Person with ID ${personId} not found in group with ID ${groupId}`); + } + + return relation; + } +} \ No newline at end of file diff --git a/backend/src/modules/projects/controllers/projects.controller.ts b/backend/src/modules/projects/controllers/projects.controller.ts new file mode 100644 index 0000000..bb36b13 --- /dev/null +++ b/backend/src/modules/projects/controllers/projects.controller.ts @@ -0,0 +1,73 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + HttpCode, + HttpStatus, + Query, +} from '@nestjs/common'; +import { ProjectsService } from '../services/projects.service'; +import { CreateProjectDto } from '../dto/create-project.dto'; +import { UpdateProjectDto } from '../dto/update-project.dto'; + +@Controller('projects') +export class ProjectsController { + constructor(private readonly projectsService: ProjectsService) {} + + /** + * Create a new project + */ + @Post() + @HttpCode(HttpStatus.CREATED) + create(@Body() createProjectDto: CreateProjectDto) { + return this.projectsService.create(createProjectDto); + } + + /** + * Get all projects or filter by owner ID + */ + @Get() + findAll(@Query('ownerId') ownerId?: string) { + if (ownerId) { + return this.projectsService.findByOwnerId(ownerId); + } + return this.projectsService.findAll(); + } + + /** + * Get a project by ID + */ + @Get(':id') + findOne(@Param('id') id: string) { + return this.projectsService.findById(id); + } + + /** + * Update a project + */ + @Patch(':id') + update(@Param('id') id: string, @Body() updateProjectDto: UpdateProjectDto) { + return this.projectsService.update(id, updateProjectDto); + } + + /** + * Delete a project + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id') id: string) { + return this.projectsService.remove(id); + } + + /** + * Check if a user has access to a project + */ + @Get(':id/check-access/:userId') + checkUserAccess(@Param('id') id: string, @Param('userId') userId: string) { + return this.projectsService.checkUserAccess(id, userId); + } +} \ No newline at end of file diff --git a/backend/src/modules/projects/dto/create-project.dto.ts b/backend/src/modules/projects/dto/create-project.dto.ts new file mode 100644 index 0000000..e5a3bd1 --- /dev/null +++ b/backend/src/modules/projects/dto/create-project.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsNotEmpty, IsOptional, IsObject, IsUUID } from 'class-validator'; + +/** + * DTO for creating a new project + */ +export class CreateProjectDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsOptional() + description?: string; + + @IsUUID() + @IsNotEmpty() + ownerId: string; + + @IsObject() + @IsOptional() + settings?: Record; +} \ No newline at end of file diff --git a/backend/src/modules/projects/dto/update-project.dto.ts b/backend/src/modules/projects/dto/update-project.dto.ts new file mode 100644 index 0000000..a295384 --- /dev/null +++ b/backend/src/modules/projects/dto/update-project.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsOptional, IsObject, IsUUID } from 'class-validator'; + +/** + * DTO for updating a project + */ +export class UpdateProjectDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsUUID() + @IsOptional() + ownerId?: string; + + @IsObject() + @IsOptional() + settings?: Record; +} \ No newline at end of file diff --git a/backend/src/modules/projects/projects.module.ts b/backend/src/modules/projects/projects.module.ts new file mode 100644 index 0000000..5469044 --- /dev/null +++ b/backend/src/modules/projects/projects.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProjectsController } from './controllers/projects.controller'; +import { ProjectsService } from './services/projects.service'; + +@Module({ + controllers: [ProjectsController], + providers: [ProjectsService], + exports: [ProjectsService], +}) +export class ProjectsModule {} \ No newline at end of file diff --git a/backend/src/modules/projects/services/projects.service.ts b/backend/src/modules/projects/services/projects.service.ts new file mode 100644 index 0000000..0cbd5b1 --- /dev/null +++ b/backend/src/modules/projects/services/projects.service.ts @@ -0,0 +1,108 @@ +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; +import { eq, and } from 'drizzle-orm'; +import { DRIZZLE } from '../../../database/database.module'; +import * as schema from '../../../database/schema'; +import { CreateProjectDto } from '../dto/create-project.dto'; +import { UpdateProjectDto } from '../dto/update-project.dto'; + +@Injectable() +export class ProjectsService { + constructor(@Inject(DRIZZLE) private readonly db: any) {} + + /** + * Create a new project + */ + async create(createProjectDto: CreateProjectDto) { + const [project] = await this.db + .insert(schema.projects) + .values(createProjectDto) + .returning(); + return project; + } + + /** + * Find all projects + */ + async findAll() { + return this.db.select().from(schema.projects); + } + + /** + * Find projects by owner ID + */ + async findByOwnerId(ownerId: string) { + return this.db + .select() + .from(schema.projects) + .where(eq(schema.projects.ownerId, ownerId)); + } + + /** + * Find a project by ID + */ + async findById(id: string) { + const [project] = await this.db + .select() + .from(schema.projects) + .where(eq(schema.projects.id, id)); + + if (!project) { + throw new NotFoundException(`Project with ID ${id} not found`); + } + + return project; + } + + /** + * Update a project + */ + async update(id: string, updateProjectDto: UpdateProjectDto) { + const [project] = await this.db + .update(schema.projects) + .set({ + ...updateProjectDto, + updatedAt: new Date(), + }) + .where(eq(schema.projects.id, id)) + .returning(); + + if (!project) { + throw new NotFoundException(`Project with ID ${id} not found`); + } + + return project; + } + + /** + * Delete a project + */ + async remove(id: string) { + const [project] = await this.db + .delete(schema.projects) + .where(eq(schema.projects.id, id)) + .returning(); + + if (!project) { + throw new NotFoundException(`Project with ID ${id} not found`); + } + + return project; + } + + /** + * Check if a user has access to a project + */ + async checkUserAccess(projectId: string, userId: string) { + const [project] = await this.db + .select() + .from(schema.projects) + .where( + and( + eq(schema.projects.id, projectId), + eq(schema.projects.ownerId, userId) + ) + ); + + return !!project; + } +} \ No newline at end of file diff --git a/backend/src/modules/users/controllers/users.controller.ts b/backend/src/modules/users/controllers/users.controller.ts new file mode 100644 index 0000000..149938e --- /dev/null +++ b/backend/src/modules/users/controllers/users.controller.ts @@ -0,0 +1,77 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { UsersService } from '../services/users.service'; +import { CreateUserDto } from '../dto/create-user.dto'; +import { UpdateUserDto } from '../dto/update-user.dto'; + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + /** + * Create a new user + */ + @Post() + @HttpCode(HttpStatus.CREATED) + create(@Body() createUserDto: CreateUserDto) { + return this.usersService.create(createUserDto); + } + + /** + * Get all users + */ + @Get() + findAll() { + return this.usersService.findAll(); + } + + /** + * Get a user by ID + */ + @Get(':id') + findOne(@Param('id') id: string) { + return this.usersService.findById(id); + } + + /** + * Update a user + */ + @Patch(':id') + update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { + return this.usersService.update(id, updateUserDto); + } + + /** + * Delete a user + */ + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + remove(@Param('id') id: string) { + return this.usersService.remove(id); + } + + /** + * Update GDPR consent timestamp + */ + @Post(':id/gdpr-consent') + updateGdprConsent(@Param('id') id: string) { + return this.usersService.updateGdprConsent(id); + } + + /** + * Export user data (for GDPR compliance) + */ + @Get(':id/export-data') + exportUserData(@Param('id') id: string) { + return this.usersService.exportUserData(id); + } +} \ No newline at end of file diff --git a/backend/src/modules/users/dto/create-user.dto.ts b/backend/src/modules/users/dto/create-user.dto.ts new file mode 100644 index 0000000..4fae9e5 --- /dev/null +++ b/backend/src/modules/users/dto/create-user.dto.ts @@ -0,0 +1,22 @@ +import { IsString, IsNotEmpty, IsOptional, IsObject } from 'class-validator'; + +/** + * DTO for creating a new user + */ +export class CreateUserDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsOptional() + avatar?: string; + + @IsString() + @IsNotEmpty() + githubId: string; + + @IsObject() + @IsOptional() + metadata?: Record; +} \ No newline at end of file diff --git a/backend/src/modules/users/dto/update-user.dto.ts b/backend/src/modules/users/dto/update-user.dto.ts new file mode 100644 index 0000000..136f31c --- /dev/null +++ b/backend/src/modules/users/dto/update-user.dto.ts @@ -0,0 +1,28 @@ +import { IsString, IsOptional, IsObject, IsDate } from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * DTO for updating a user + */ +export class UpdateUserDto { + @IsString() + @IsOptional() + name?: string; + + @IsString() + @IsOptional() + avatar?: string; + + @IsString() + @IsOptional() + githubId?: string; + + @IsDate() + @IsOptional() + @Type(() => Date) + gdprTimestamp?: Date; + + @IsObject() + @IsOptional() + metadata?: Record; +} \ No newline at end of file diff --git a/backend/src/modules/users/services/users.service.ts b/backend/src/modules/users/services/users.service.ts new file mode 100644 index 0000000..c888701 --- /dev/null +++ b/backend/src/modules/users/services/users.service.ts @@ -0,0 +1,119 @@ +import { Injectable, NotFoundException, Inject } from '@nestjs/common'; +import { eq } from 'drizzle-orm'; +import { DRIZZLE } from '../../../database/database.module'; +import * as schema from '../../../database/schema'; +import { CreateUserDto } from '../dto/create-user.dto'; +import { UpdateUserDto } from '../dto/update-user.dto'; + +@Injectable() +export class UsersService { + constructor(@Inject(DRIZZLE) private readonly db: any) {} + + /** + * Create a new user + */ + async create(createUserDto: CreateUserDto) { + const [user] = await this.db + .insert(schema.users) + .values({ + ...createUserDto, + gdprTimestamp: new Date(), + }) + .returning(); + return user; + } + + /** + * Find all users + */ + async findAll() { + return this.db.select().from(schema.users); + } + + /** + * Find a user by ID + */ + async findById(id: string) { + const [user] = await this.db + .select() + .from(schema.users) + .where(eq(schema.users.id, id)); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + /** + * Find a user by GitHub ID + */ + async findByGithubId(githubId: string) { + const [user] = await this.db + .select() + .from(schema.users) + .where(eq(schema.users.githubId, githubId)); + + return user; + } + + /** + * Update a user + */ + async update(id: string, updateUserDto: UpdateUserDto) { + const [user] = await this.db + .update(schema.users) + .set({ + ...updateUserDto, + updatedAt: new Date(), + }) + .where(eq(schema.users.id, id)) + .returning(); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + /** + * Delete a user + */ + async remove(id: string) { + const [user] = await this.db + .delete(schema.users) + .where(eq(schema.users.id, id)) + .returning(); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + /** + * Update GDPR consent timestamp + */ + async updateGdprConsent(id: string) { + return this.update(id, { gdprTimestamp: new Date() }); + } + + /** + * Export user data (for GDPR compliance) + */ + async exportUserData(id: string) { + const user = await this.findById(id); + const projects = await this.db + .select() + .from(schema.projects) + .where(eq(schema.projects.ownerId, id)); + + return { + user, + projects, + }; + } +} \ No newline at end of file diff --git a/backend/src/modules/users/users.module.ts b/backend/src/modules/users/users.module.ts new file mode 100644 index 0000000..4a49570 --- /dev/null +++ b/backend/src/modules/users/users.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UsersController } from './controllers/users.controller'; +import { UsersService } from './services/users.service'; + +@Module({ + controllers: [UsersController], + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} \ No newline at end of file diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts new file mode 100644 index 0000000..4df6580 --- /dev/null +++ b/backend/test/app.e2e-spec.ts @@ -0,0 +1,25 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { App } from 'supertest/types'; +import { AppModule } from './../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeEach(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + it('/ (GET)', () => { + return request(app.getHttpServer()) + .get('/') + .expect(200) + .expect('Hello World!'); + }); +}); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json new file mode 100644 index 0000000..e9d912f --- /dev/null +++ b/backend/test/jest-e2e.json @@ -0,0 +1,9 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + } +} diff --git a/backend/tsconfig.build.json b/backend/tsconfig.build.json new file mode 100644 index 0000000..64f86c6 --- /dev/null +++ b/backend/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..304a7f0 --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "forceConsistentCasingInFileNames": true, + "noImplicitAny": false, + "strictBindCallApply": false, + "noFallthroughCasesInSwitch": false + } +}