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 @@
+
+
+
+
+[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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## 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
+ }
+}