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[]
|
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
|
||||||
|
|||||||
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 { 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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([]);
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user