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

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