feat: implement UsersModule with service, controller, and DTOs
Added UsersModule to manage user-related operations. Includes UsersService for CRUD operations, consent updates, and 2FA handling. Implemented UsersController with endpoints for public profiles, account management, and admin user listing. Integrated with CryptoService and database schemas.
This commit is contained in:
11
backend/src/users/dto/update-consent.dto.ts
Normal file
11
backend/src/users/dto/update-consent.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from "class-validator";
|
||||
|
||||
export class UpdateConsentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
termsVersion!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
privacyVersion!: string;
|
||||
}
|
||||
8
backend/src/users/dto/update-user.dto.ts
Normal file
8
backend/src/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
displayName?: string;
|
||||
}
|
||||
107
backend/src/users/users.controller.ts
Normal file
107
backend/src/users/users.controller.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { Roles } from "../auth/decorators/roles.decorator";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { RolesGuard } from "../auth/guards/roles.guard";
|
||||
import type { AuthenticatedRequest } from "../common/interfaces/request.interface";
|
||||
import { UpdateConsentDto } from "./dto/update-consent.dto";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Controller("users")
|
||||
export class UsersController {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
// Gestion administrative des utilisateurs
|
||||
@Get("admin")
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles("admin")
|
||||
findAll(
|
||||
@Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number,
|
||||
@Query("offset", new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
) {
|
||||
return this.usersService.findAll(limit, offset);
|
||||
}
|
||||
|
||||
// Listing public d'un profil
|
||||
@Get("public/:username")
|
||||
findPublicProfile(@Param("username") username: string) {
|
||||
return this.usersService.findPublicProfile(username);
|
||||
}
|
||||
|
||||
// Gestion de son propre compte
|
||||
@Get("me")
|
||||
@UseGuards(AuthGuard)
|
||||
findMe(@Req() req: AuthenticatedRequest) {
|
||||
return this.usersService.findOneWithPrivateData(req.user.sub);
|
||||
}
|
||||
|
||||
@Get("me/export")
|
||||
@UseGuards(AuthGuard)
|
||||
exportMe(@Req() req: AuthenticatedRequest) {
|
||||
return this.usersService.exportUserData(req.user.sub);
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@UseGuards(AuthGuard)
|
||||
updateMe(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
) {
|
||||
return this.usersService.update(req.user.sub, updateUserDto);
|
||||
}
|
||||
|
||||
@Patch("me/consent")
|
||||
@UseGuards(AuthGuard)
|
||||
updateConsent(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Body() consentDto: UpdateConsentDto,
|
||||
) {
|
||||
return this.usersService.updateConsent(
|
||||
req.user.sub,
|
||||
consentDto.termsVersion,
|
||||
consentDto.privacyVersion,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete("me")
|
||||
@UseGuards(AuthGuard)
|
||||
removeMe(@Req() req: AuthenticatedRequest) {
|
||||
return this.usersService.remove(req.user.sub);
|
||||
}
|
||||
|
||||
// Double Authentification (2FA)
|
||||
@Post("me/2fa/setup")
|
||||
@UseGuards(AuthGuard)
|
||||
setup2fa(@Req() req: AuthenticatedRequest) {
|
||||
return this.authService.generateTwoFactorSecret(req.user.sub);
|
||||
}
|
||||
|
||||
@Post("me/2fa/enable")
|
||||
@UseGuards(AuthGuard)
|
||||
enable2fa(@Req() req: AuthenticatedRequest, @Body("token") token: string) {
|
||||
return this.authService.enableTwoFactor(req.user.sub, token);
|
||||
}
|
||||
|
||||
@Post("me/2fa/disable")
|
||||
@UseGuards(AuthGuard)
|
||||
disable2fa(@Req() req: AuthenticatedRequest, @Body("token") token: string) {
|
||||
return this.authService.disableTwoFactor(req.user.sub, token);
|
||||
}
|
||||
}
|
||||
14
backend/src/users/users.module.ts
Normal file
14
backend/src/users/users.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, CryptoModule, AuthModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
224
backend/src/users/users.service.ts
Normal file
224
backend/src/users/users.service.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { CryptoService } from "../crypto/crypto.service";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
import { contents, favorites, users } from "../database/schemas";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
private readonly databaseService: DatabaseService,
|
||||
private readonly cryptoService: CryptoService,
|
||||
) {}
|
||||
|
||||
async create(data: {
|
||||
username: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
emailHash: string;
|
||||
}) {
|
||||
const pgpKey = this.cryptoService.getPgpEncryptionKey();
|
||||
|
||||
const [newUser] = await this.databaseService.db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: data.username,
|
||||
email: sql`pgp_sym_encrypt(${data.email}, ${pgpKey})`,
|
||||
emailHash: data.emailHash,
|
||||
passwordHash: data.passwordHash,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async findByEmailHash(emailHash: string) {
|
||||
const pgpKey = this.cryptoService.getPgpEncryptionKey();
|
||||
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: sql<string>`pgp_sym_decrypt(${users.email}, ${pgpKey})`,
|
||||
passwordHash: users.passwordHash,
|
||||
status: users.status,
|
||||
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.emailHash, emailHash))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findOneWithPrivateData(uuid: string) {
|
||||
const pgpKey = this.cryptoService.getPgpEncryptionKey();
|
||||
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: sql<string>`pgp_sym_decrypt(${users.email}, ${pgpKey})`,
|
||||
displayName: users.displayName,
|
||||
status: users.status,
|
||||
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.uuid, uuid))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findAll(limit: number, offset: number) {
|
||||
const totalCountResult = await this.databaseService.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users);
|
||||
|
||||
const totalCount = Number(totalCountResult[0].count);
|
||||
|
||||
const data = await this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
status: users.status,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return { data, totalCount };
|
||||
}
|
||||
|
||||
async findPublicProfile(username: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
displayName: users.displayName,
|
||||
createdAt: users.createdAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.username, username))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findOne(uuid: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.uuid, uuid))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async update(uuid: string, data: UpdateUserDto) {
|
||||
return await this.databaseService.db
|
||||
.update(users)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async updateConsent(
|
||||
uuid: string,
|
||||
termsVersion: string,
|
||||
privacyVersion: string,
|
||||
) {
|
||||
return await this.databaseService.db
|
||||
.update(users)
|
||||
.set({
|
||||
termsVersion,
|
||||
privacyVersion,
|
||||
gdprAcceptedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async setTwoFactorSecret(uuid: string, secret: string) {
|
||||
const pgpKey = this.cryptoService.getPgpEncryptionKey();
|
||||
return await this.databaseService.db
|
||||
.update(users)
|
||||
.set({
|
||||
twoFactorSecret: sql`pgp_sym_encrypt(${secret}, ${pgpKey})`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async toggleTwoFactor(uuid: string, enabled: boolean) {
|
||||
return await this.databaseService.db
|
||||
.update(users)
|
||||
.set({
|
||||
isTwoFactorEnabled: enabled,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async getTwoFactorSecret(uuid: string): Promise<string | null> {
|
||||
const pgpKey = this.cryptoService.getPgpEncryptionKey();
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
secret: sql<string>`pgp_sym_decrypt(${users.twoFactorSecret}, ${pgpKey})`,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.uuid, uuid))
|
||||
.limit(1);
|
||||
|
||||
return result[0]?.secret || null;
|
||||
}
|
||||
|
||||
async exportUserData(uuid: string) {
|
||||
const user = await this.findOneWithPrivateData(uuid);
|
||||
if (!user) return null;
|
||||
|
||||
const userContents = await this.databaseService.db
|
||||
.select()
|
||||
.from(contents)
|
||||
.where(eq(contents.userId, uuid));
|
||||
|
||||
const userFavorites = await this.databaseService.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(eq(favorites.userId, uuid));
|
||||
|
||||
return {
|
||||
profile: user,
|
||||
contents: userContents,
|
||||
favorites: userFavorites,
|
||||
exportedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(uuid: string) {
|
||||
return await this.databaseService.db.transaction(async (tx) => {
|
||||
// Soft delete de l'utilisateur
|
||||
const userResult = await tx
|
||||
.update(users)
|
||||
.set({ status: "deleted", deletedAt: new Date() })
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
|
||||
// Soft delete de tous ses contenus
|
||||
await tx
|
||||
.update(contents)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(contents.userId, uuid));
|
||||
|
||||
return userResult;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user