Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/transactions/dto/transaction-document.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
// @ts-nocheck

import { IsString, IsIn, IsOptional, IsNumber, Min } from 'class-validator';

export const DOCUMENT_TYPE_ENUM = [
Expand All @@ -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;
Expand Down
259 changes: 190 additions & 69 deletions src/transactions/transaction-documents.service.ts
Original file line number Diff line number Diff line change
@@ -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<Prisma.TransactionGetPayload<object>, '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<TxAccess> {
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<Document> {
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,
Expand All @@ -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,
Expand All @@ -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<DocumentWithVersions[]> {
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<DocumentWithVersions> {
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<DocumentVersion> {
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;
Expand All @@ -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 },
Expand All @@ -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<VersionWithUploader[]> {
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<Document> {
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 } });
}
}
Loading