From 7e8e440c4351cf1bd8ac8adbfa0fd660a1e65f17 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 23:43:14 +0530 Subject: [PATCH] Add transaction comments API --- .../migration.sql | 16 ++++ prisma/schema.prisma | 17 ++++ .../dto/create-transaction-comment.dto.ts | 3 + src/transactions/transactions.controller.ts | 17 ++++ src/transactions/transactions.service.ts | 77 +++++++++++++++++++ src/view/view.service.ts | 4 + test/transactions.service.spec.ts | 62 +++++++++++++++ test/utils/mock-prisma.ts | 4 + 8 files changed, 200 insertions(+) create mode 100644 prisma/migrations/20260716001700_transaction_comments/migration.sql create mode 100644 src/transactions/dto/create-transaction-comment.dto.ts diff --git a/prisma/migrations/20260716001700_transaction_comments/migration.sql b/prisma/migrations/20260716001700_transaction_comments/migration.sql new file mode 100644 index 0000000..5619581 --- /dev/null +++ b/prisma/migrations/20260716001700_transaction_comments/migration.sql @@ -0,0 +1,16 @@ +CREATE TABLE "TransactionComment" ( + "id" TEXT NOT NULL, + "rawTransactionId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "body" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TransactionComment_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "TransactionComment_rawTransactionId_createdAt_idx" ON "TransactionComment"("rawTransactionId", "createdAt"); +CREATE INDEX "TransactionComment_userId_createdAt_idx" ON "TransactionComment"("userId", "createdAt"); + +ALTER TABLE "TransactionComment" ADD CONSTRAINT "TransactionComment_rawTransactionId_fkey" FOREIGN KEY ("rawTransactionId") REFERENCES "TransactionRaw"("id") ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "TransactionComment" ADD CONSTRAINT "TransactionComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fa3b7ee..a849004 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -56,6 +56,7 @@ model User { bills Bill[] billPayments BillPayment[] creditScoreEntries CreditScoreEntry[] + transactionComments TransactionComment[] } model NotificationPreference { @@ -356,6 +357,7 @@ model TransactionRaw { account Account @relation(fields: [accountId], references: [id]) derived TransactionDerived? ruleExecutions RuleExecution[] + comments TransactionComment[] } model TransactionDerived { @@ -374,6 +376,21 @@ model TransactionDerived { raw TransactionRaw @relation(fields: [rawTransactionId], references: [id]) } +model TransactionComment { + id String @id @default(uuid()) + rawTransactionId String + userId String + body String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + raw TransactionRaw @relation(fields: [rawTransactionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([rawTransactionId, createdAt]) + @@index([userId, createdAt]) +} + model CsvImportMapping { id String @id @default(uuid()) userId String diff --git a/src/transactions/dto/create-transaction-comment.dto.ts b/src/transactions/dto/create-transaction-comment.dto.ts new file mode 100644 index 0000000..29c9fc6 --- /dev/null +++ b/src/transactions/dto/create-transaction-comment.dto.ts @@ -0,0 +1,3 @@ +export class CreateTransactionCommentDto { + body!: string; +} diff --git a/src/transactions/transactions.controller.ts b/src/transactions/transactions.controller.ts index 7480060..2ab1d93 100644 --- a/src/transactions/transactions.controller.ts +++ b/src/transactions/transactions.controller.ts @@ -13,6 +13,7 @@ import { import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express"; import { ok } from "../common/response"; import { UpdateDerivedDto } from "./dto/update-derived.dto"; +import { CreateTransactionCommentDto } from "./dto/create-transaction-comment.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; import { TransactionsService } from "./transactions.service"; import { CurrentUser } from "../common/decorators/current-user.decorator"; @@ -149,4 +150,20 @@ export class TransactionsController { const data = await this.transactionsService.updateDerived(userId, id, payload); return ok(data); } + + @Get(":id/comments") + async comments(@CurrentUser() userId: string, @Param("id") id: string) { + const data = await this.transactionsService.listComments(userId, id); + return ok(data); + } + + @Post(":id/comments") + async createComment( + @CurrentUser() userId: string, + @Param("id") id: string, + @Body() payload: CreateTransactionCommentDto, + ) { + const data = await this.transactionsService.createComment(userId, id, payload); + return ok(data); + } } diff --git a/src/transactions/transactions.service.ts b/src/transactions/transactions.service.ts index 28a2d0d..a747159 100644 --- a/src/transactions/transactions.service.ts +++ b/src/transactions/transactions.service.ts @@ -10,6 +10,7 @@ import { ViewRefService } from "../common/view-ref.service"; import { ExportsService } from "../exports/exports.service"; import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; +import { CreateTransactionCommentDto } from "./dto/create-transaction-comment.dto"; const UI_PAGE_SIZE_LIMIT = 25; const TRANSACTION_ATTRIBUTIONS = ["mine", "yours", "ours"] as const; @@ -536,6 +537,52 @@ export class TransactionsService { return derived; } + async listComments(userId: string, handle: string) { + const transactionId = await this.resolveOwnedTransactionId(userId, handle); + const comments = await this.prisma.transactionComment.findMany({ + where: { rawTransactionId: transactionId }, + include: { + user: { + select: { id: true, email: true, fullName: true }, + }, + }, + orderBy: { createdAt: "asc" }, + take: 100, + }); + + return comments.map((comment: any) => this.serializeComment(comment, userId)); + } + + async createComment(userId: string, handle: string, payload: CreateTransactionCommentDto) { + const body = this.normalizeCommentBody(payload.body); + const transactionId = await this.resolveOwnedTransactionId(userId, handle); + const comment = await this.prisma.transactionComment.create({ + data: { + rawTransactionId: transactionId, + userId, + body, + }, + include: { + user: { + select: { id: true, email: true, fullName: true }, + }, + }, + }); + + await this.prisma.auditLog.create({ + data: { + userId, + action: "transaction.comment.create", + metadata: { + rawTransactionId: transactionId, + commentId: comment.id, + }, + }, + }); + + return this.serializeComment(comment, userId); + } + private async resolveAccountHandle(userId: string, handle: string) { try { return this.opaqueIds.decode("account", userId, handle); @@ -565,6 +612,36 @@ export class TransactionsService { } } + private async resolveOwnedTransactionId(userId: string, handle: string) { + const transactionId = await this.resolveTransactionHandle(userId, handle); + const tx = await this.prisma.transactionRaw.findFirst({ + where: { id: transactionId, account: { userId } }, + select: { id: true }, + }); + if (!tx) throw new BadRequestException("Transaction not found."); + return tx.id; + } + + private normalizeCommentBody(body: unknown) { + const value = typeof body === "string" ? body.trim() : ""; + if (!value) throw new BadRequestException("Comment body is required."); + if (value.length > 1000) throw new BadRequestException("Comment body must be 1000 characters or fewer."); + return value; + } + + private serializeComment(comment: any, viewerUserId: string) { + return { + id: this.opaqueIds.encode("transaction_comment", viewerUserId, comment.id), + body: comment.body, + createdAt: comment.createdAt, + updatedAt: comment.updatedAt, + author: { + displayName: comment.user?.fullName ?? comment.user?.email ?? "LedgerOne user", + email: comment.user?.email ?? null, + }, + }; + } + async sync(userId: string, startDate: string, endDate: string) { const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate); await this.syncGoogleSheetsBestEffort(userId, "plaid_sync"); diff --git a/src/view/view.service.ts b/src/view/view.service.ts index 428803b..62ca512 100644 --- a/src/view/view.service.ts +++ b/src/view/view.service.ts @@ -134,6 +134,9 @@ export class ViewService { amount: true, description: true, source: true, + _count: { + select: { comments: true }, + }, derived: { select: { userCategory: true, @@ -179,6 +182,7 @@ export class ViewService { }, status: row.derived ? "user" : "raw", hidden: row.derived?.isHidden ?? false, + commentCount: row._count?.comments ?? 0, })), total, page, diff --git a/test/transactions.service.spec.ts b/test/transactions.service.spec.ts index d40eefd..8093d8c 100644 --- a/test/transactions.service.spec.ts +++ b/test/transactions.service.spec.ts @@ -166,6 +166,68 @@ describe("TransactionsService", () => { expect(prisma.transactionDerived.upsert).not.toHaveBeenCalled(); }); + it("creates and lists transaction comments for owned transactions", async () => { + const { service, prisma, opaqueIds } = createService(); + prisma.transactionRaw.findFirst.mockResolvedValue({ id: "tx_1" }); + prisma.transactionComment.create.mockResolvedValue({ + id: "comment_1", + rawTransactionId: "tx_1", + userId: "user_1", + body: "Discuss with partner", + createdAt: new Date("2026-07-16T00:00:00.000Z"), + updatedAt: new Date("2026-07-16T00:00:00.000Z"), + user: { id: "user_1", email: "owner@example.com", fullName: "Owner User" }, + }); + prisma.transactionComment.findMany.mockResolvedValue([ + { + id: "comment_1", + rawTransactionId: "tx_1", + userId: "user_1", + body: "Discuss with partner", + createdAt: new Date("2026-07-16T00:00:00.000Z"), + updatedAt: new Date("2026-07-16T00:00:00.000Z"), + user: { id: "user_1", email: "owner@example.com", fullName: "Owner User" }, + }, + ]); + prisma.auditLog.create.mockResolvedValue({}); + + const created = await service.createComment("user_1", "opaque_transaction_tx_1", { body: " Discuss with partner " }); + const listed = await service.listComments("user_1", "opaque_transaction_tx_1"); + + expect(created).toEqual(expect.objectContaining({ + id: "opaque_transaction_comment_comment_1", + body: "Discuss with partner", + author: { displayName: "Owner User", email: "owner@example.com" }, + })); + expect(listed).toHaveLength(1); + expect(prisma.transactionComment.create).toHaveBeenCalledWith(expect.objectContaining({ + data: { + rawTransactionId: "tx_1", + userId: "user_1", + body: "Discuss with partner", + }, + })); + expect(prisma.transactionComment.findMany).toHaveBeenCalledWith(expect.objectContaining({ + where: { rawTransactionId: "tx_1" }, + take: 100, + })); + expect(prisma.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: "user_1", + action: "transaction.comment.create", + metadata: expect.objectContaining({ rawTransactionId: "tx_1", commentId: "comment_1" }), + }), + }); + expect(opaqueIds.decode).toHaveBeenCalledWith("transaction", "user_1", "opaque_transaction_tx_1"); + }); + + it("rejects blank transaction comments", async () => { + const { service, prisma } = createService(); + + await expect(service.createComment("user_1", "opaque_transaction_tx_1", { body: " " })).rejects.toBeInstanceOf(BadRequestException); + expect(prisma.transactionComment.create).not.toHaveBeenCalled(); + }); + it("caps UI transaction list responses at 25 rows", async () => { const { service, prisma } = createService(); prisma.transactionRaw.findMany.mockResolvedValue([]); diff --git a/test/utils/mock-prisma.ts b/test/utils/mock-prisma.ts index f360d96..1087a99 100644 --- a/test/utils/mock-prisma.ts +++ b/test/utils/mock-prisma.ts @@ -44,6 +44,10 @@ export const createPrismaMock = () => ({ create: jest.fn(), upsert: jest.fn() }, + transactionComment: { + create: jest.fn(), + findMany: jest.fn() + }, transactionDerived: { create: jest.fn(), findMany: jest.fn(),