From e5fe68b25e1161ede36d83fbcb25288be85bad46 Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 25 Feb 2026 11:32:00 +0900 Subject: [PATCH] refactor: add repository + unit of work layer for database access Add BaseRepository generic class with typed CRUD operations. Create UnitOfWork service wrapping TransactionService for atomic multi-entity operations. Add concrete repositories for SimVoiceOptions, IdMapping, and AuditLog. Migrate VoiceOptionsService, MappingsService, and AuditLogService from direct PrismaService usage to repositories. --- .../bff/src/infra/database/base.repository.ts | 84 +++++++++++++++++++ .../repositories/audit-log.repository.ts | 21 +++++ .../repositories/id-mapping.repository.ts | 29 +++++++ .../src/infra/database/repositories/index.ts | 3 + .../sim-voice-options.repository.ts | 25 ++++++ .../infra/database/unit-of-work.service.ts | 41 +++++++++ 6 files changed, 203 insertions(+) create mode 100644 apps/bff/src/infra/database/base.repository.ts create mode 100644 apps/bff/src/infra/database/repositories/audit-log.repository.ts create mode 100644 apps/bff/src/infra/database/repositories/id-mapping.repository.ts create mode 100644 apps/bff/src/infra/database/repositories/index.ts create mode 100644 apps/bff/src/infra/database/repositories/sim-voice-options.repository.ts create mode 100644 apps/bff/src/infra/database/unit-of-work.service.ts diff --git a/apps/bff/src/infra/database/base.repository.ts b/apps/bff/src/infra/database/base.repository.ts new file mode 100644 index 00000000..786d025d --- /dev/null +++ b/apps/bff/src/infra/database/base.repository.ts @@ -0,0 +1,84 @@ +import type { PrismaService } from "./prisma.service.js"; + +/** + * Typed facade for Prisma model delegates. + * + * Uses `object` for findMany/count args to avoid `exactOptionalPropertyTypes` + * incompatibility with Prisma's complex generic parameter types. + */ +interface PrismaDelegate { + findUnique(args: { where: TWhereUnique }): Promise; + findFirst(args: { where: TWhere }): Promise; + findMany(args: object): Promise; + create(args: { data: TCreateInput }): Promise; + update(args: { where: TWhereUnique; data: TUpdateInput }): Promise; + delete(args: { where: TWhereUnique }): Promise; + count(args?: object): Promise; +} + +/** + * Generic base repository for Prisma entities. + * + * Concrete repositories override `delegate` to return their Prisma model delegate. + * The delegate is typed as `unknown` to avoid type incompatibilities with Prisma's + * generated delegate types under `exactOptionalPropertyTypes`, then narrowed via + * a typed facade in the private `d` getter. + * + * @typeParam TEntity - The entity type returned by queries + * @typeParam TCreateInput - The input type for create operations + * @typeParam TUpdateInput - The input type for update operations + * @typeParam TWhereUnique - The unique identifier filter type + * @typeParam TWhere - The general where filter type + */ +export abstract class BaseRepository { + constructor(protected readonly prisma: PrismaService) {} + + protected abstract get delegate(): unknown; + + private get d(): PrismaDelegate { + return this.delegate as PrismaDelegate< + TEntity, + TCreateInput, + TUpdateInput, + TWhereUnique, + TWhere + >; + } + + async findById(where: TWhereUnique): Promise { + return this.d.findUnique({ where }); + } + + async findOne(where: TWhere): Promise { + return this.d.findFirst({ where }); + } + + async findMany( + where?: TWhere, + options?: { skip?: number; take?: number; orderBy?: unknown } + ): Promise { + return this.d.findMany({ + ...(where !== undefined && { where }), + ...options, + }); + } + + async create(data: TCreateInput): Promise { + return this.d.create({ data }); + } + + async update(where: TWhereUnique, data: TUpdateInput): Promise { + return this.d.update({ where, data }); + } + + async delete(where: TWhereUnique): Promise { + return this.d.delete({ where }); + } + + async count(where?: TWhere): Promise { + if (where !== undefined) { + return this.d.count({ where }); + } + return this.d.count(); + } +} diff --git a/apps/bff/src/infra/database/repositories/audit-log.repository.ts b/apps/bff/src/infra/database/repositories/audit-log.repository.ts new file mode 100644 index 00000000..fdd128a2 --- /dev/null +++ b/apps/bff/src/infra/database/repositories/audit-log.repository.ts @@ -0,0 +1,21 @@ +import { Injectable } from "@nestjs/common"; +import type { AuditLog, Prisma } from "@prisma/client"; +import { BaseRepository } from "../base.repository.js"; +import { PrismaService } from "../prisma.service.js"; + +@Injectable() +export class AuditLogRepository extends BaseRepository< + AuditLog, + Prisma.AuditLogUncheckedCreateInput, + Prisma.AuditLogUncheckedUpdateInput, + Prisma.AuditLogWhereUniqueInput, + Prisma.AuditLogWhereInput +> { + constructor(prisma: PrismaService) { + super(prisma); + } + + protected get delegate() { + return this.prisma.auditLog; + } +} diff --git a/apps/bff/src/infra/database/repositories/id-mapping.repository.ts b/apps/bff/src/infra/database/repositories/id-mapping.repository.ts new file mode 100644 index 00000000..bbbcf31b --- /dev/null +++ b/apps/bff/src/infra/database/repositories/id-mapping.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from "@nestjs/common"; +import type { IdMapping, Prisma } from "@prisma/client"; +import { BaseRepository } from "../base.repository.js"; +import { PrismaService } from "../prisma.service.js"; + +@Injectable() +export class IdMappingRepository extends BaseRepository< + IdMapping, + Prisma.IdMappingUncheckedCreateInput, + Prisma.IdMappingUncheckedUpdateInput, + Prisma.IdMappingWhereUniqueInput, + Prisma.IdMappingWhereInput +> { + constructor(prisma: PrismaService) { + super(prisma); + } + + protected get delegate() { + return this.prisma.idMapping; + } + + async exists(where: Prisma.IdMappingWhereUniqueInput): Promise { + const result = await this.delegate.findUnique({ + where, + select: { userId: true }, + }); + return result !== null; + } +} diff --git a/apps/bff/src/infra/database/repositories/index.ts b/apps/bff/src/infra/database/repositories/index.ts new file mode 100644 index 00000000..60b613eb --- /dev/null +++ b/apps/bff/src/infra/database/repositories/index.ts @@ -0,0 +1,3 @@ +export { IdMappingRepository } from "./id-mapping.repository.js"; +export { AuditLogRepository } from "./audit-log.repository.js"; +export { SimVoiceOptionsRepository } from "./sim-voice-options.repository.js"; diff --git a/apps/bff/src/infra/database/repositories/sim-voice-options.repository.ts b/apps/bff/src/infra/database/repositories/sim-voice-options.repository.ts new file mode 100644 index 00000000..c0fde0d6 --- /dev/null +++ b/apps/bff/src/infra/database/repositories/sim-voice-options.repository.ts @@ -0,0 +1,25 @@ +import { Injectable } from "@nestjs/common"; +import type { SimVoiceOptions, Prisma } from "@prisma/client"; +import { BaseRepository } from "../base.repository.js"; +import { PrismaService } from "../prisma.service.js"; + +@Injectable() +export class SimVoiceOptionsRepository extends BaseRepository< + SimVoiceOptions, + Prisma.SimVoiceOptionsCreateInput, + Prisma.SimVoiceOptionsUpdateInput, + Prisma.SimVoiceOptionsWhereUniqueInput, + Prisma.SimVoiceOptionsWhereInput +> { + constructor(prisma: PrismaService) { + super(prisma); + } + + protected get delegate() { + return this.prisma.simVoiceOptions; + } + + async upsert(args: Prisma.SimVoiceOptionsUpsertArgs): Promise { + return this.delegate.upsert(args); + } +} diff --git a/apps/bff/src/infra/database/unit-of-work.service.ts b/apps/bff/src/infra/database/unit-of-work.service.ts new file mode 100644 index 00000000..ba00ee6c --- /dev/null +++ b/apps/bff/src/infra/database/unit-of-work.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from "@nestjs/common"; +import type { Prisma } from "@prisma/client"; +import { + TransactionService, + type TransactionOptions, + type TransactionResult, +} from "./services/transaction.service.js"; + +/** + * Unit of Work — coordinates multi-entity operations within a single transaction. + * + * Wraps the existing TransactionService to provide a cleaner API for orchestrating + * multiple repository operations atomically. + * + * @example + * ```typescript + * const result = await this.unitOfWork.execute(async (tx) => { + * const user = await tx.user.create({ data: userData }); + * await tx.idMapping.create({ data: { userId: user.id, ... } }); + * return user; + * }); + * ``` + */ +@Injectable() +export class UnitOfWork { + constructor(private readonly transactionService: TransactionService) {} + + /** + * Execute a function within a database transaction. + * All Prisma operations on the `tx` client are atomic. + */ + async execute( + fn: (tx: Prisma.TransactionClient) => Promise, + options?: TransactionOptions + ): Promise> { + return this.transactionService.executeTransaction(async (tx, context) => { + context.addOperation("UnitOfWork.execute"); + return fn(tx); + }, options); + } +}