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 + } +}