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:
Mathis H (Avnyr) 2025-05-15 17:09:36 +02:00
parent f6f0888bd7
commit 9f99b80784
63 changed files with 2838 additions and 0 deletions

56
backend/.gitignore vendored Normal file
View File

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

36
backend/Dockerfile Normal file
View 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
View 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>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ 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
View 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
View 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
View 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"
}
}

View File

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

View File

@ -0,0 +1,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
View 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 {}

View File

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

View File

@ -0,0 +1,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 {}

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

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

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

View 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");

View 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']);

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

View 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';

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

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

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

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

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

View 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],
}),
}));

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

View 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
View 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();

View 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 {}

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

View 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;
},
);

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

View 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') {}

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

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

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

View File

@ -0,0 +1,14 @@
/**
* Interface for tokens response
*/
export interface TokensResponse {
/**
* JWT access token
*/
accessToken: string;
/**
* JWT refresh token
*/
refreshToken: string;
}

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

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

View 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');
}
}
}

View 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');
}
}
}

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

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

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

View 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 {}

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

View File

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

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

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

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

View File

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

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

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

View 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 {}

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

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

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

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

View 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,
};
}
}

View 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 {}

View 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!');
});
});

View File

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

View File

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

21
backend/tsconfig.json Normal file
View 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
}
}