Add transaction comments API
This commit is contained in:
parent
00fb77ec1a
commit
7e8e440c43
@ -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;
|
||||
@ -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
|
||||
|
||||
3
src/transactions/dto/create-transaction-comment.dto.ts
Normal file
3
src/transactions/dto/create-transaction-comment.dto.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export class CreateTransactionCommentDto {
|
||||
body!: string;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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([]);
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user