feat: add modular services and repositories for improved code organization
Introduce repository pattern across multiple services, including `favorites`, `tags`, `sessions`, `reports`, `auth`, and more. Decouple crypto functionalities into modular services like `HashingService`, `JwtService`, and `EncryptionService`. Improve testability and maintainability by simplifying dependencies and consolidating utility logic.
This commit is contained in:
158
backend/src/users/repositories/users.repository.ts
Normal file
158
backend/src/users/repositories/users.repository.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { and, eq, lte, sql } from "drizzle-orm";
|
||||
import { DatabaseService } from "../../database/database.service";
|
||||
import { users, contents, favorites } from "../../database/schemas";
|
||||
import type { UpdateUserDto } from "../dto/update-user.dto";
|
||||
|
||||
@Injectable()
|
||||
export class UsersRepository {
|
||||
constructor(private readonly databaseService: DatabaseService) {}
|
||||
|
||||
async create(data: {
|
||||
username: string;
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
emailHash: string;
|
||||
}) {
|
||||
const [newUser] = await this.databaseService.db
|
||||
.insert(users)
|
||||
.values(data)
|
||||
.returning();
|
||||
return newUser;
|
||||
}
|
||||
|
||||
async findByEmailHash(emailHash: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
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 result = await this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
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 countAll() {
|
||||
const result = await this.databaseService.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users);
|
||||
return Number(result[0].count);
|
||||
}
|
||||
|
||||
async findAll(limit: number, offset: number) {
|
||||
return 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);
|
||||
}
|
||||
|
||||
async findByUsername(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: any) {
|
||||
return await this.databaseService.db
|
||||
.update(users)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
}
|
||||
|
||||
async getTwoFactorSecret(uuid: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
secret: users.twoFactorSecret,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.uuid, uuid))
|
||||
.limit(1);
|
||||
return result[0]?.secret || null;
|
||||
}
|
||||
|
||||
async getUserContents(uuid: string) {
|
||||
return await this.databaseService.db
|
||||
.select()
|
||||
.from(contents)
|
||||
.where(eq(contents.userId, uuid));
|
||||
}
|
||||
|
||||
async getUserFavorites(uuid: string) {
|
||||
return await this.databaseService.db
|
||||
.select()
|
||||
.from(favorites)
|
||||
.where(eq(favorites.userId, uuid));
|
||||
}
|
||||
|
||||
async softDeleteUserAndContents(uuid: string) {
|
||||
return await this.databaseService.db.transaction(async (tx) => {
|
||||
const userResult = await tx
|
||||
.update(users)
|
||||
.set({ status: "deleted", deletedAt: new Date() })
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
|
||||
await tx
|
||||
.update(contents)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(eq(contents.userId, uuid));
|
||||
|
||||
return userResult;
|
||||
});
|
||||
}
|
||||
|
||||
async purgeDeleted(before: Date) {
|
||||
return await this.databaseService.db
|
||||
.delete(users)
|
||||
.where(and(eq(users.status, "deleted"), lte(users.deletedAt, before)))
|
||||
.returning();
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,12 @@ import { CryptoModule } from "../crypto/crypto.module";
|
||||
import { DatabaseModule } from "../database/database.module";
|
||||
import { UsersController } from "./users.controller";
|
||||
import { UsersService } from "./users.service";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, CryptoModule, AuthModule],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
providers: [UsersService, UsersRepository],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -12,61 +12,46 @@ jest.mock("jose", () => ({
|
||||
}));
|
||||
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { CryptoService } from "../crypto/crypto.service";
|
||||
import { DatabaseService } from "../database/database.service";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { UsersService } from "./users.service";
|
||||
import { UsersRepository } from "./repositories/users.repository";
|
||||
|
||||
describe("UsersService", () => {
|
||||
let service: UsersService;
|
||||
let repository: UsersRepository;
|
||||
|
||||
const mockDb = {
|
||||
insert: jest.fn(),
|
||||
select: jest.fn(),
|
||||
const mockUsersRepository = {
|
||||
create: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
findByEmailHash: jest.fn(),
|
||||
findOneWithPrivateData: jest.fn(),
|
||||
countAll: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findByUsername: jest.fn(),
|
||||
update: jest.fn(),
|
||||
transaction: jest.fn().mockImplementation((cb) => cb(mockDb)),
|
||||
getTwoFactorSecret: jest.fn(),
|
||||
getUserContents: jest.fn(),
|
||||
getUserFavorites: jest.fn(),
|
||||
softDeleteUserAndContents: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCryptoService = {
|
||||
getPgpEncryptionKey: jest.fn().mockReturnValue("mock-pgp-key"),
|
||||
const mockCacheManager = {
|
||||
del: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const chain = {
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
offset: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
// biome-ignore lint/suspicious/noThenProperty: Fine for testing purposes
|
||||
then: jest.fn().mockImplementation(function (cb) {
|
||||
return Promise.resolve(this).then(cb);
|
||||
}),
|
||||
};
|
||||
|
||||
const mockImplementation = () => Object.assign(Promise.resolve([]), chain);
|
||||
for (const mock of Object.values(chain)) {
|
||||
if (mock !== chain.then) {
|
||||
mock.mockImplementation(mockImplementation);
|
||||
}
|
||||
}
|
||||
Object.assign(mockDb, chain);
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
UsersService,
|
||||
{ provide: DatabaseService, useValue: { db: mockDb } },
|
||||
{ provide: CryptoService, useValue: mockCryptoService },
|
||||
{ provide: UsersRepository, useValue: mockUsersRepository },
|
||||
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UsersService>(UsersService);
|
||||
repository = module.get<UsersRepository>(UsersRepository);
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
@@ -81,24 +66,24 @@ describe("UsersService", () => {
|
||||
passwordHash: "p1",
|
||||
emailHash: "eh1",
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([{ uuid: "uuid1", ...data }]);
|
||||
mockUsersRepository.create.mockResolvedValue({ uuid: "uuid1", ...data });
|
||||
|
||||
const result = await service.create(data);
|
||||
|
||||
expect(result.uuid).toBe("uuid1");
|
||||
expect(mockDb.insert).toHaveBeenCalled();
|
||||
expect(repository.create).toHaveBeenCalledWith(data);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should find a user", async () => {
|
||||
mockDb.limit.mockResolvedValue([{ uuid: "uuid1" }]);
|
||||
mockUsersRepository.findOne.mockResolvedValue({ uuid: "uuid1" });
|
||||
const result = await service.findOne("uuid1");
|
||||
expect(result.uuid).toBe("uuid1");
|
||||
});
|
||||
|
||||
it("should return null if not found", async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
mockUsersRepository.findOne.mockResolvedValue(null);
|
||||
const result = await service.findOne("uuid1");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
@@ -106,7 +91,7 @@ describe("UsersService", () => {
|
||||
|
||||
describe("update", () => {
|
||||
it("should update a user", async () => {
|
||||
mockDb.returning.mockResolvedValue([{ uuid: "uuid1", displayName: "New" }]);
|
||||
mockUsersRepository.update.mockResolvedValue([{ uuid: "uuid1", displayName: "New" }]);
|
||||
const result = await service.update("uuid1", { displayName: "New" });
|
||||
expect(result[0].displayName).toBe("New");
|
||||
});
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { Injectable, Logger, Inject } from "@nestjs/common";
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Cache } from "cache-manager";
|
||||
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 { UsersRepository } from "./repositories/users.repository";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
|
||||
@Injectable()
|
||||
@@ -16,8 +9,7 @@ export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
|
||||
constructor(
|
||||
private readonly databaseService: DatabaseService,
|
||||
private readonly cryptoService: CryptoService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
@Inject(CACHE_MANAGER) private cacheManager: Cache,
|
||||
) {}
|
||||
|
||||
@@ -33,109 +25,37 @@ export class UsersService {
|
||||
passwordHash: string;
|
||||
emailHash: string;
|
||||
}) {
|
||||
const [newUser] = await this.databaseService.db
|
||||
.insert(users)
|
||||
.values({
|
||||
username: data.username,
|
||||
email: data.email,
|
||||
emailHash: data.emailHash,
|
||||
passwordHash: data.passwordHash,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return newUser;
|
||||
return await this.usersRepository.create(data);
|
||||
}
|
||||
|
||||
async findByEmailHash(emailHash: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
passwordHash: users.passwordHash,
|
||||
status: users.status,
|
||||
isTwoFactorEnabled: users.isTwoFactorEnabled,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.emailHash, emailHash))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
return await this.usersRepository.findByEmailHash(emailHash);
|
||||
}
|
||||
|
||||
async findOneWithPrivateData(uuid: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
uuid: users.uuid,
|
||||
username: users.username,
|
||||
email: users.email,
|
||||
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;
|
||||
return await this.usersRepository.findOneWithPrivateData(uuid);
|
||||
}
|
||||
|
||||
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);
|
||||
const [data, totalCount] = await Promise.all([
|
||||
this.usersRepository.findAll(limit, offset),
|
||||
this.usersRepository.countAll(),
|
||||
]);
|
||||
|
||||
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;
|
||||
return await this.usersRepository.findByUsername(username);
|
||||
}
|
||||
|
||||
async findOne(uuid: string) {
|
||||
const result = await this.databaseService.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.uuid, uuid))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
return await this.usersRepository.findOne(uuid);
|
||||
}
|
||||
|
||||
async update(uuid: string, data: UpdateUserDto) {
|
||||
this.logger.log(`Updating user profile for ${uuid}`);
|
||||
const result = await this.databaseService.db
|
||||
.update(users)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
const result = await this.usersRepository.update(uuid, data);
|
||||
|
||||
if (result[0]) {
|
||||
await this.clearUserCache(result[0].username);
|
||||
@@ -148,65 +68,37 @@ export class UsersService {
|
||||
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();
|
||||
return await this.usersRepository.update(uuid, {
|
||||
termsVersion,
|
||||
privacyVersion,
|
||||
gdprAcceptedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async setTwoFactorSecret(uuid: string, secret: string) {
|
||||
return await this.databaseService.db
|
||||
.update(users)
|
||||
.set({
|
||||
twoFactorSecret: secret,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(users.uuid, uuid))
|
||||
.returning();
|
||||
return await this.usersRepository.update(uuid, {
|
||||
twoFactorSecret: secret,
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
return await this.usersRepository.update(uuid, {
|
||||
isTwoFactorEnabled: enabled,
|
||||
});
|
||||
}
|
||||
|
||||
async getTwoFactorSecret(uuid: string): Promise<string | null> {
|
||||
const result = await this.databaseService.db
|
||||
.select({
|
||||
secret: users.twoFactorSecret,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.uuid, uuid))
|
||||
.limit(1);
|
||||
|
||||
return result[0]?.secret || null;
|
||||
return await this.usersRepository.getTwoFactorSecret(uuid);
|
||||
}
|
||||
|
||||
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));
|
||||
const [userContents, userFavorites] = await Promise.all([
|
||||
this.usersRepository.getUserContents(uuid),
|
||||
this.usersRepository.getUserFavorites(uuid),
|
||||
]);
|
||||
|
||||
return {
|
||||
profile: user,
|
||||
@@ -217,21 +109,6 @@ export class UsersService {
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
return await this.usersRepository.softDeleteUserAndContents(uuid);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user