feat: implement authentication and database modules with relations and group management
Added new authentication strategies (JWT and GitHub OAuth), guards, and controllers. Implemented database module, schema with relations, and group management features, including CRD operations and person-to-group associations. Integrated validation and CORS configuration.
This commit is contained in:
parent
f6f0888bd7
commit
9f99b80784
56
backend/.gitignore
vendored
Normal file
56
backend/.gitignore
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
/build
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
# Tests
|
||||
/coverage
|
||||
/.nyc_output
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# temp directory
|
||||
.temp
|
||||
.tmp
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
36
backend/Dockerfile
Normal file
36
backend/Dockerfile
Normal file
@ -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"]
|
136
backend/README.md
Normal file
136
backend/README.md
Normal file
@ -0,0 +1,136 @@
|
||||
<p align="center">
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
||||
</p>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## 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).
|
26
backend/drizzle.config.ts
Normal file
26
backend/drizzle.config.ts
Normal file
@ -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 || "<PASSWORD>"),
|
||||
ssl: false,
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
8
backend/nest-cli.json
Normal file
8
backend/nest-cli.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
97
backend/package.json
Normal file
97
backend/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
22
backend/src/app.controller.spec.ts
Normal file
22
backend/src/app.controller.spec.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
12
backend/src/app.controller.ts
Normal file
12
backend/src/app.controller.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
28
backend/src/app.module.ts
Normal file
28
backend/src/app.module.ts
Normal file
@ -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 {}
|
8
backend/src/app.service.ts
Normal file
8
backend/src/app.service.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
33
backend/src/database/database.module.ts
Normal file
33
backend/src/database/database.module.ts
Normal file
@ -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 {}
|
97
backend/src/database/database.service.ts
Normal file
97
backend/src/database/database.service.ts
Normal file
@ -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<typeof drizzle>;
|
||||
|
||||
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<string>('DATABASE_URL');
|
||||
if (databaseUrl) {
|
||||
return databaseUrl;
|
||||
}
|
||||
|
||||
// If DATABASE_URL is not provided, construct it from individual variables
|
||||
const password = this.configService.get<string>('POSTGRES_PASSWORD');
|
||||
const username = this.configService.get<string>('POSTGRES_USER');
|
||||
const host = this.configService.get<string>('POSTGRES_HOST');
|
||||
const port = this.configService.get<string>('POSTGRES_PORT');
|
||||
const database = this.configService.get<string>('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;
|
||||
}
|
||||
}
|
55
backend/src/database/migrations/generate-migrations.ts
Normal file
55
backend/src/database/migrations/generate-migrations.ts
Normal file
@ -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);
|
||||
});
|
91
backend/src/database/migrations/migrate.ts
Normal file
91
backend/src/database/migrations/migrate.ts
Normal file
@ -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);
|
||||
});
|
||||
}
|
17
backend/src/database/schema/db-schema.ts
Normal file
17
backend/src/database/schema/db-schema.ts
Normal file
@ -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");
|
16
backend/src/database/schema/enums.ts
Normal file
16
backend/src/database/schema/enums.ts
Normal file
@ -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']);
|
25
backend/src/database/schema/groups.ts
Normal file
25
backend/src/database/schema/groups.ts
Normal file
@ -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;
|
23
backend/src/database/schema/index.ts
Normal file
23
backend/src/database/schema/index.ts
Normal file
@ -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';
|
25
backend/src/database/schema/personToGroup.ts
Normal file
25
backend/src/database/schema/personToGroup.ts
Normal file
@ -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;
|
25
backend/src/database/schema/personToTag.ts
Normal file
25
backend/src/database/schema/personToTag.ts
Normal file
@ -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;
|
35
backend/src/database/schema/persons.ts
Normal file
35
backend/src/database/schema/persons.ts
Normal file
@ -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;
|
25
backend/src/database/schema/projectToTag.ts
Normal file
25
backend/src/database/schema/projectToTag.ts
Normal file
@ -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;
|
27
backend/src/database/schema/projects.ts
Normal file
27
backend/src/database/schema/projects.ts
Normal file
@ -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;
|
102
backend/src/database/schema/relations.ts
Normal file
102
backend/src/database/schema/relations.ts
Normal file
@ -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],
|
||||
}),
|
||||
}));
|
25
backend/src/database/schema/tags.ts
Normal file
25
backend/src/database/schema/tags.ts
Normal file
@ -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;
|
27
backend/src/database/schema/users.ts
Normal file
27
backend/src/database/schema/users.ts
Normal file
@ -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;
|
33
backend/src/main.ts
Normal file
33
backend/src/main.ts
Normal file
@ -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<string>('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<string>('API_PREFIX', 'api'));
|
||||
|
||||
const port = configService.get<number>('PORT', 3000);
|
||||
await app.listen(port);
|
||||
console.log(`Application is running on: http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
37
backend/src/modules/auth/auth.module.ts
Normal file
37
backend/src/modules/auth/auth.module.ts
Normal file
@ -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<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_EXPIRATION') || '15m',
|
||||
},
|
||||
}),
|
||||
}),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
GithubStrategy,
|
||||
JwtStrategy,
|
||||
JwtRefreshStrategy,
|
||||
],
|
||||
exports: [AuthService, JwtStrategy, JwtRefreshStrategy, PassportModule],
|
||||
})
|
||||
export class AuthModule {}
|
78
backend/src/modules/auth/controllers/auth.controller.ts
Normal file
78
backend/src/modules/auth/controllers/auth.controller.ts
Normal file
@ -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<string>('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;
|
||||
}
|
||||
}
|
18
backend/src/modules/auth/decorators/get-user.decorator.ts
Normal file
18
backend/src/modules/auth/decorators/get-user.decorator.ts
Normal file
@ -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;
|
||||
},
|
||||
);
|
13
backend/src/modules/auth/dto/refresh-token.dto.ts
Normal file
13
backend/src/modules/auth/dto/refresh-token.dto.ts
Normal file
@ -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;
|
||||
}
|
8
backend/src/modules/auth/guards/github-auth.guard.ts
Normal file
8
backend/src/modules/auth/guards/github-auth.guard.ts
Normal file
@ -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') {}
|
18
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
18
backend/src/modules/auth/guards/jwt-auth.guard.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
18
backend/src/modules/auth/guards/jwt-refresh.guard.ts
Normal file
18
backend/src/modules/auth/guards/jwt-refresh.guard.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
24
backend/src/modules/auth/interfaces/jwt-payload.interface.ts
Normal file
24
backend/src/modules/auth/interfaces/jwt-payload.interface.ts
Normal file
@ -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;
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Interface for tokens response
|
||||
*/
|
||||
export interface TokensResponse {
|
||||
/**
|
||||
* JWT access token
|
||||
*/
|
||||
accessToken: string;
|
||||
|
||||
/**
|
||||
* JWT refresh token
|
||||
*/
|
||||
refreshToken: string;
|
||||
}
|
96
backend/src/modules/auth/services/auth.service.ts
Normal file
96
backend/src/modules/auth/services/auth.service.ts
Normal file
@ -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<TokensResponse> {
|
||||
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<string>('JWT_REFRESH_EXPIRATION') || '7d',
|
||||
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh tokens using a valid refresh token
|
||||
*/
|
||||
async refreshTokens(userId: string, refreshToken: string): Promise<TokensResponse> {
|
||||
// Verify the refresh token
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync(refreshToken, {
|
||||
secret: this.configService.get<string>('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;
|
||||
}
|
||||
}
|
50
backend/src/modules/auth/strategies/github.strategy.ts
Normal file
50
backend/src/modules/auth/strategies/github.strategy.ts
Normal file
@ -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<string>('GITHUB_CLIENT_ID') || 'dummy-client-id';
|
||||
const clientSecret = configService.get<string>('GITHUB_CLIENT_SECRET') || 'dummy-client-secret';
|
||||
const callbackURL = configService.get<string>('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;
|
||||
}
|
||||
}
|
51
backend/src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
51
backend/src/modules/auth/strategies/jwt-refresh.strategy.ts
Normal file
@ -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<string>('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');
|
||||
}
|
||||
}
|
||||
}
|
38
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
38
backend/src/modules/auth/strategies/jwt.strategy.ts
Normal file
@ -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<string>('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');
|
||||
}
|
||||
}
|
||||
}
|
94
backend/src/modules/groups/controllers/groups.controller.ts
Normal file
94
backend/src/modules/groups/controllers/groups.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
27
backend/src/modules/groups/dto/create-group.dto.ts
Normal file
27
backend/src/modules/groups/dto/create-group.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
27
backend/src/modules/groups/dto/update-group.dto.ts
Normal file
27
backend/src/modules/groups/dto/update-group.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
10
backend/src/modules/groups/groups.module.ts
Normal file
10
backend/src/modules/groups/groups.module.ts
Normal file
@ -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 {}
|
167
backend/src/modules/groups/services/groups.service.ts
Normal file
167
backend/src/modules/groups/services/groups.service.ts
Normal file
@ -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));
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
83
backend/src/modules/persons/dto/create-person.dto.ts
Normal file
83
backend/src/modules/persons/dto/create-person.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
68
backend/src/modules/persons/dto/update-person.dto.ts
Normal file
68
backend/src/modules/persons/dto/update-person.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
145
backend/src/modules/persons/services/persons.service.ts
Normal file
145
backend/src/modules/persons/services/persons.service.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
||||
}
|
22
backend/src/modules/projects/dto/create-project.dto.ts
Normal file
22
backend/src/modules/projects/dto/create-project.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
22
backend/src/modules/projects/dto/update-project.dto.ts
Normal file
22
backend/src/modules/projects/dto/update-project.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
10
backend/src/modules/projects/projects.module.ts
Normal file
10
backend/src/modules/projects/projects.module.ts
Normal file
@ -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 {}
|
108
backend/src/modules/projects/services/projects.service.ts
Normal file
108
backend/src/modules/projects/services/projects.service.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
77
backend/src/modules/users/controllers/users.controller.ts
Normal file
77
backend/src/modules/users/controllers/users.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
22
backend/src/modules/users/dto/create-user.dto.ts
Normal file
22
backend/src/modules/users/dto/create-user.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
28
backend/src/modules/users/dto/update-user.dto.ts
Normal file
28
backend/src/modules/users/dto/update-user.dto.ts
Normal file
@ -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<string, any>;
|
||||
}
|
119
backend/src/modules/users/services/users.service.ts
Normal file
119
backend/src/modules/users/services/users.service.ts
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
10
backend/src/modules/users/users.module.ts
Normal file
10
backend/src/modules/users/users.module.ts
Normal file
@ -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 {}
|
25
backend/test/app.e2e-spec.ts
Normal file
25
backend/test/app.e2e-spec.ts
Normal file
@ -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<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it('/ (GET)', () => {
|
||||
return request(app.getHttpServer())
|
||||
.get('/')
|
||||
.expect(200)
|
||||
.expect('Hello World!');
|
||||
});
|
||||
});
|
9
backend/test/jest-e2e.json
Normal file
9
backend/test/jest-e2e.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
4
backend/tsconfig.build.json
Normal file
4
backend/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
21
backend/tsconfig.json
Normal file
21
backend/tsconfig.json
Normal file
@ -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
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user