Add transaction comments API

This commit is contained in:
MOHAN 2026-07-16 23:43:14 +05:30
parent 00fb77ec1a
commit 7e8e440c43
8 changed files with 200 additions and 0 deletions

View File

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

View File

@ -56,6 +56,7 @@ model User {
bills Bill[] bills Bill[]
billPayments BillPayment[] billPayments BillPayment[]
creditScoreEntries CreditScoreEntry[] creditScoreEntries CreditScoreEntry[]
transactionComments TransactionComment[]
} }
model NotificationPreference { model NotificationPreference {
@ -356,6 +357,7 @@ model TransactionRaw {
account Account @relation(fields: [accountId], references: [id]) account Account @relation(fields: [accountId], references: [id])
derived TransactionDerived? derived TransactionDerived?
ruleExecutions RuleExecution[] ruleExecutions RuleExecution[]
comments TransactionComment[]
} }
model TransactionDerived { model TransactionDerived {
@ -374,6 +376,21 @@ model TransactionDerived {
raw TransactionRaw @relation(fields: [rawTransactionId], references: [id]) 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 { model CsvImportMapping {
id String @id @default(uuid()) id String @id @default(uuid())
userId String userId String

View File

@ -0,0 +1,3 @@
export class CreateTransactionCommentDto {
body!: string;
}

View File

@ -13,6 +13,7 @@ import {
import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express"; import { FileInterceptor, FilesInterceptor } from "@nestjs/platform-express";
import { ok } from "../common/response"; import { ok } from "../common/response";
import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { UpdateDerivedDto } from "./dto/update-derived.dto";
import { CreateTransactionCommentDto } from "./dto/create-transaction-comment.dto";
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
import { TransactionsService } from "./transactions.service"; import { TransactionsService } from "./transactions.service";
import { CurrentUser } from "../common/decorators/current-user.decorator"; import { CurrentUser } from "../common/decorators/current-user.decorator";
@ -149,4 +150,20 @@ export class TransactionsController {
const data = await this.transactionsService.updateDerived(userId, id, payload); const data = await this.transactionsService.updateDerived(userId, id, payload);
return ok(data); 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);
}
} }

View File

@ -10,6 +10,7 @@ import { ViewRefService } from "../common/view-ref.service";
import { ExportsService } from "../exports/exports.service"; import { ExportsService } from "../exports/exports.service";
import { UpdateDerivedDto } from "./dto/update-derived.dto"; import { UpdateDerivedDto } from "./dto/update-derived.dto";
import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto"; import { CreateManualTransactionDto } from "./dto/create-manual-transaction.dto";
import { CreateTransactionCommentDto } from "./dto/create-transaction-comment.dto";
const UI_PAGE_SIZE_LIMIT = 25; const UI_PAGE_SIZE_LIMIT = 25;
const TRANSACTION_ATTRIBUTIONS = ["mine", "yours", "ours"] as const; const TRANSACTION_ATTRIBUTIONS = ["mine", "yours", "ours"] as const;
@ -536,6 +537,52 @@ export class TransactionsService {
return derived; 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) { private async resolveAccountHandle(userId: string, handle: string) {
try { try {
return this.opaqueIds.decode("account", userId, handle); 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) { async sync(userId: string, startDate: string, endDate: string) {
const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate); const result = await this.plaidService.syncTransactionsForUser(userId, startDate, endDate);
await this.syncGoogleSheetsBestEffort(userId, "plaid_sync"); await this.syncGoogleSheetsBestEffort(userId, "plaid_sync");

View File

@ -134,6 +134,9 @@ export class ViewService {
amount: true, amount: true,
description: true, description: true,
source: true, source: true,
_count: {
select: { comments: true },
},
derived: { derived: {
select: { select: {
userCategory: true, userCategory: true,
@ -179,6 +182,7 @@ export class ViewService {
}, },
status: row.derived ? "user" : "raw", status: row.derived ? "user" : "raw",
hidden: row.derived?.isHidden ?? false, hidden: row.derived?.isHidden ?? false,
commentCount: row._count?.comments ?? 0,
})), })),
total, total,
page, page,

View File

@ -166,6 +166,68 @@ describe("TransactionsService", () => {
expect(prisma.transactionDerived.upsert).not.toHaveBeenCalled(); 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 () => { it("caps UI transaction list responses at 25 rows", async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.transactionRaw.findMany.mockResolvedValue([]); prisma.transactionRaw.findMany.mockResolvedValue([]);

View File

@ -44,6 +44,10 @@ export const createPrismaMock = () => ({
create: jest.fn(), create: jest.fn(),
upsert: jest.fn() upsert: jest.fn()
}, },
transactionComment: {
create: jest.fn(),
findMany: jest.fn()
},
transactionDerived: { transactionDerived: {
create: jest.fn(), create: jest.fn(),
findMany: jest.fn(), findMany: jest.fn(),