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.
This commit is contained in:
barsa 2026-02-25 11:32:00 +09:00
parent 2d076cf6d4
commit e5fe68b25e
6 changed files with 203 additions and 0 deletions

View File

@ -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<TEntity, TCreateInput, TUpdateInput, TWhereUnique, TWhere> {
findUnique(args: { where: TWhereUnique }): Promise<TEntity | null>;
findFirst(args: { where: TWhere }): Promise<TEntity | null>;
findMany(args: object): Promise<TEntity[]>;
create(args: { data: TCreateInput }): Promise<TEntity>;
update(args: { where: TWhereUnique; data: TUpdateInput }): Promise<TEntity>;
delete(args: { where: TWhereUnique }): Promise<TEntity>;
count(args?: object): Promise<number>;
}
/**
* 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<TEntity, TCreateInput, TUpdateInput, TWhereUnique, TWhere> {
constructor(protected readonly prisma: PrismaService) {}
protected abstract get delegate(): unknown;
private get d(): PrismaDelegate<TEntity, TCreateInput, TUpdateInput, TWhereUnique, TWhere> {
return this.delegate as PrismaDelegate<
TEntity,
TCreateInput,
TUpdateInput,
TWhereUnique,
TWhere
>;
}
async findById(where: TWhereUnique): Promise<TEntity | null> {
return this.d.findUnique({ where });
}
async findOne(where: TWhere): Promise<TEntity | null> {
return this.d.findFirst({ where });
}
async findMany(
where?: TWhere,
options?: { skip?: number; take?: number; orderBy?: unknown }
): Promise<TEntity[]> {
return this.d.findMany({
...(where !== undefined && { where }),
...options,
});
}
async create(data: TCreateInput): Promise<TEntity> {
return this.d.create({ data });
}
async update(where: TWhereUnique, data: TUpdateInput): Promise<TEntity> {
return this.d.update({ where, data });
}
async delete(where: TWhereUnique): Promise<TEntity> {
return this.d.delete({ where });
}
async count(where?: TWhere): Promise<number> {
if (where !== undefined) {
return this.d.count({ where });
}
return this.d.count();
}
}

View File

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

View File

@ -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<boolean> {
const result = await this.delegate.findUnique({
where,
select: { userId: true },
});
return result !== null;
}
}

View File

@ -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";

View File

@ -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<SimVoiceOptions> {
return this.delegate.upsert(args);
}
}

View File

@ -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<T>(
fn: (tx: Prisma.TransactionClient) => Promise<T>,
options?: TransactionOptions
): Promise<TransactionResult<T>> {
return this.transactionService.executeTransaction(async (tx, context) => {
context.addOperation("UnitOfWork.execute");
return fn(tx);
}, options);
}
}