From 69165273528a6867c0e2b98b8ba646048b47fc1a Mon Sep 17 00:00:00 2001 From: jethrojohn739-max Date: Tue, 30 Jun 2026 01:19:19 +0000 Subject: [PATCH] fix(#768): eliminate redundant DB reads in transaction-documents.service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Consolidate sequential ensureTransactionExists + document queries into batched ([tx select, doc findFirst]) round-trips for findOne, addVersion, getVersions, and remove — reducing reads per call from 2 to 1 batched operation. - list: batches transaction access-select + document.findMany in a single batch (was 2 sequential queries). - getVersions: was 3 sequential queries (tx check, doc existence, versions findMany with uploadedBy); now 1 batched round-trip with versions and uploader embedded via include. - Use select: { id, buyerId, sellerId } on transaction queries to fetch only the access-control fields needed, reducing data transfer. - Remove @ts-nocheck from service and dto; add proper TypeScript types (TxAccess, DocumentWithVersions, VersionWithUploader, DocumentTypeValue). - Replace as any casts with correct Prisma-typed field assignments. --- .../dto/transaction-document.dto.ts | 6 +- .../transaction-documents.service.ts | 259 +++++++++++++----- 2 files changed, 193 insertions(+), 72 deletions(-) diff --git a/src/transactions/dto/transaction-document.dto.ts b/src/transactions/dto/transaction-document.dto.ts index 51f53023..d033cf1f 100644 --- a/src/transactions/dto/transaction-document.dto.ts +++ b/src/transactions/dto/transaction-document.dto.ts @@ -1,5 +1,3 @@ -// @ts-nocheck - import { IsString, IsIn, IsOptional, IsNumber, Min } from 'class-validator'; export const DOCUMENT_TYPE_ENUM = [ @@ -12,9 +10,11 @@ export const DOCUMENT_TYPE_ENUM = [ 'FLOOR_PLAN', ] as const; +export type DocumentTypeValue = (typeof DOCUMENT_TYPE_ENUM)[number]; + export class AttachDocumentDto { @IsIn(DOCUMENT_TYPE_ENUM) - documentType: (typeof DOCUMENT_TYPE_ENUM)[number]; + documentType: DocumentTypeValue; @IsString() fileName: string; diff --git a/src/transactions/transaction-documents.service.ts b/src/transactions/transaction-documents.service.ts index b223ad0c..d11cd2e0 100644 --- a/src/transactions/transaction-documents.service.ts +++ b/src/transactions/transaction-documents.service.ts @@ -1,23 +1,75 @@ -// @ts-nocheck - -import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Document, DocumentVersion, Prisma } from '@prisma/client'; import { PrismaService } from '../database/prisma.service'; import { AttachDocumentDto, AddVersionDto } from './dto/transaction-document.dto'; +/** Minimal transaction fields needed for access control. */ +type TxAccess = Pick, 'id' | 'buyerId' | 'sellerId'>; + +/** Document with its ordered versions. */ +type DocumentWithVersions = Document & { versions: DocumentVersion[] }; + +/** Document version with the uploader's public profile. */ +type VersionWithUploader = DocumentVersion & { + uploadedBy: { id: string; firstName: string; lastName: string; email: string } | null; +}; + @Injectable() export class TransactionDocumentsService { constructor(private readonly prisma: PrismaService) {} - /** Attach a new document to a transaction and record version 1. */ - async attach(transactionId: string, dto: AttachDocumentDto, userId: string, userRole?: string) { - const tx = await this.ensureTransactionExists(transactionId); + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /** + * Throws NotFoundException when the transaction does not exist. + * Used only by `attach` which has no document to co-fetch. + */ + private async fetchTxAccess(transactionId: string): Promise { + const tx = await this.prisma.transaction.findUnique({ + where: { id: transactionId }, + select: { id: true, buyerId: true, sellerId: true }, + }); + if (!tx) throw new NotFoundException(`Transaction ${transactionId} not found`); + return tx; + } + + private assertAccess(tx: TxAccess, userId: string, userRole?: string): void { + const isParty = tx.buyerId === userId || tx.sellerId === userId; + const isPrivileged = userRole === 'ADMIN' || userRole === 'AGENT'; + if (!isParty && !isPrivileged) { + throw new ForbiddenException('Access denied to this transaction document'); + } + } + + // --------------------------------------------------------------------------- + // Public methods + // --------------------------------------------------------------------------- + + /** + * Attach a new document to a transaction and record version 1. + * 2 queries: (1) transaction access-select, (2) document create + version create + * (version create is a separate write — not avoidable without denormalising). + */ + async attach( + transactionId: string, + dto: AttachDocumentDto, + userId: string, + userRole?: string, + ): Promise { + const tx = await this.fetchTxAccess(transactionId); this.assertAccess(tx, userId, userRole); const document = await this.prisma.document.create({ data: { transactionId, userId, - documentType: dto.documentType as any, + documentType: dto.documentType, fileName: dto.fileName, fileUrl: dto.fileUrl, fileSize: dto.fileSize, @@ -26,10 +78,9 @@ export class TransactionDocumentsService { stage: dto.stage ?? null, category: dto.documentType.toLowerCase().replace('_', '-'), auditTrail: [], - } as any, + }, }); - // Record initial version await this.prisma.documentVersion.create({ data: { documentId: document.id, @@ -45,45 +96,88 @@ export class TransactionDocumentsService { return document; } - /** List all documents attached to a transaction. */ - async list(transactionId: string, userId: string, userRole: string) { - const tx = await this.ensureTransactionExists(transactionId); + /** + * List all documents attached to a transaction. + * Reduced from 2 sequential queries to 1 batched round-trip via + * Prisma $transaction: transaction access-select + document list run + * concurrently in the same connection checkout. + */ + async list( + transactionId: string, + userId: string, + userRole: string, + ): Promise { + const [tx, documents] = await this.prisma.$transaction([ + this.prisma.transaction.findUnique({ + where: { id: transactionId }, + select: { id: true, buyerId: true, sellerId: true }, + }), + this.prisma.document.findMany({ + where: { transactionId }, + orderBy: { createdAt: 'desc' }, + include: { versions: { orderBy: { versionNumber: 'asc' } } }, + }), + ]); + + if (!tx) throw new NotFoundException(`Transaction ${transactionId} not found`); this.assertAccess(tx, userId, userRole); - return this.prisma.document.findMany({ - where: { transactionId }, - orderBy: { createdAt: 'desc' }, - include: { versions: { orderBy: { versionNumber: 'asc' } } }, - }); + return documents; } - /** Get a single document (must belong to the transaction). */ - async findOne(transactionId: string, documentId: string, userId: string, userRole: string) { - const tx = await this.ensureTransactionExists(transactionId); - this.assertAccess(tx, userId, userRole); + /** + * Get a single document (must belong to the transaction). + * Reduced from 2 sequential queries to 1 batched round-trip: + * transaction access-select + document-with-versions fetched together. + */ + async findOne( + transactionId: string, + documentId: string, + userId: string, + userRole: string, + ): Promise { + const [tx, doc] = await this.prisma.$transaction([ + this.prisma.transaction.findUnique({ + where: { id: transactionId }, + select: { id: true, buyerId: true, sellerId: true }, + }), + this.prisma.document.findFirst({ + where: { id: documentId, transactionId }, + include: { versions: { orderBy: { versionNumber: 'asc' } } }, + }), + ]); - const doc = await this.prisma.document.findFirst({ - where: { id: documentId, transactionId }, - include: { versions: { orderBy: { versionNumber: 'asc' } } }, - }); + if (!tx) throw new NotFoundException(`Transaction ${transactionId} not found`); + this.assertAccess(tx, userId, userRole); if (!doc) throw new NotFoundException('Document not found for this transaction'); return doc; } - /** Add a new version to an existing transaction document. */ + /** + * Add a new version to an existing transaction document. + * Reduced from 2 sequential read queries to 1 batched round-trip: + * transaction access-select + document-with-versions fetched together, + * followed by the single write batch (version create + document update). + */ async addVersion( transactionId: string, documentId: string, dto: AddVersionDto, userId: string, userRole: string, - ) { - const tx = await this.ensureTransactionExists(transactionId); - this.assertAccess(tx, userId, userRole); + ): Promise { + const [tx, doc] = await this.prisma.$transaction([ + this.prisma.transaction.findUnique({ + where: { id: transactionId }, + select: { id: true, buyerId: true, sellerId: true }, + }), + this.prisma.document.findFirst({ + where: { id: documentId, transactionId }, + include: { versions: { orderBy: { versionNumber: 'asc' } } }, + }), + ]); - const doc = await this.prisma.document.findFirst({ - where: { id: documentId, transactionId }, - include: { versions: { orderBy: { versionNumber: 'asc' } } }, - }); + if (!tx) throw new NotFoundException(`Transaction ${transactionId} not found`); + this.assertAccess(tx, userId, userRole); if (!doc) throw new NotFoundException('Document not found for this transaction'); const nextVersion = (doc.versions?.length ?? 0) + 1; @@ -100,7 +194,6 @@ export class TransactionDocumentsService { changeNote: dto.changeNote, }, }), - // Update the document to point to the latest file this.prisma.document.update({ where: { id: documentId }, data: { fileUrl: dto.fileUrl, fileName: dto.fileName, fileSize: dto.fileSize }, @@ -110,47 +203,75 @@ export class TransactionDocumentsService { return version; } - /** List all versions of a document. */ - async getVersions(transactionId: string, documentId: string, userId: string, userRole: string) { - const tx = await this.ensureTransactionExists(transactionId); - this.assertAccess(tx, userId, userRole); - - const doc = await this.prisma.document.findFirst({ - where: { id: documentId, transactionId }, - }); - if (!doc) throw new NotFoundException('Document not found for this transaction'); - return this.prisma.documentVersion.findMany({ - where: { documentId }, - orderBy: { versionNumber: 'asc' }, - include: { - uploadedBy: { select: { id: true, firstName: true, lastName: true, email: true } }, - }, - }); - } + /** + * List all versions of a document. + * + * Previously made 3 sequential round-trips: + * 1. ensureTransactionExists + * 2. document.findFirst (existence check only) + * 3. documentVersion.findMany (with uploadedBy) + * + * Now consolidated to 1 batched round-trip: transaction access fields + + * document-with-versions-and-uploader fetched together. + */ + async getVersions( + transactionId: string, + documentId: string, + userId: string, + userRole: string, + ): Promise { + const [tx, doc] = await this.prisma.$transaction([ + this.prisma.transaction.findUnique({ + where: { id: transactionId }, + select: { id: true, buyerId: true, sellerId: true }, + }), + this.prisma.document.findFirst({ + where: { id: documentId, transactionId }, + include: { + versions: { + orderBy: { versionNumber: 'asc' }, + include: { + uploadedBy: { + select: { id: true, firstName: true, lastName: true, email: true }, + }, + }, + }, + }, + }), + ]); - /** Remove a document from a transaction. */ - async remove(transactionId: string, documentId: string, userId: string, userRole: string) { - const tx = await this.ensureTransactionExists(transactionId); + if (!tx) throw new NotFoundException(`Transaction ${transactionId} not found`); this.assertAccess(tx, userId, userRole); - - const doc = await this.prisma.document.findFirst({ - where: { id: documentId, transactionId }, - }); if (!doc) throw new NotFoundException('Document not found for this transaction'); - return this.prisma.document.delete({ where: { id: documentId } }); + + return doc.versions as VersionWithUploader[]; } - private async ensureTransactionExists(transactionId: string) { - const tx = await this.prisma.transaction.findUnique({ where: { id: transactionId } }); + /** + * Remove a document from a transaction. + * Reduced from 2 sequential read queries to 1 batched round-trip: + * transaction access-select + document existence check fetched together. + */ + async remove( + transactionId: string, + documentId: string, + userId: string, + userRole: string, + ): Promise { + const [tx, doc] = await this.prisma.$transaction([ + this.prisma.transaction.findUnique({ + where: { id: transactionId }, + select: { id: true, buyerId: true, sellerId: true }, + }), + this.prisma.document.findFirst({ + where: { id: documentId, transactionId }, + }), + ]); + if (!tx) throw new NotFoundException(`Transaction ${transactionId} not found`); - return tx; - } + this.assertAccess(tx, userId, userRole); + if (!doc) throw new NotFoundException('Document not found for this transaction'); - private assertAccess(tx: any, userId: string, userRole?: string) { - const isParty = tx.buyerId === userId || tx.sellerId === userId; - const isPrivileged = userRole === 'ADMIN' || userRole === 'AGENT'; - if (!isParty && !isPrivileged) { - throw new ForbiddenException('Access denied to this transaction document'); - } + return this.prisma.document.delete({ where: { id: documentId } }); } }