From 9ed3be79537f1104d64280c426db3b211693e0c2 Mon Sep 17 00:00:00 2001 From: Porfolio1 Date: Mon, 30 Mar 2026 08:39:49 +0100 Subject: [PATCH] feat(backend): soft delete for stream records --- README.md | 10 ++++ src/api/v1/streams.test.ts | 38 ++++++++++++ src/api/v1/streams.ts | 29 ++++++++- src/db/schema.ts | 1 + src/repositories/streamRepository.test.ts | 71 ++++++++++++++++++++++- src/repositories/streamRepository.ts | 25 +++++++- 6 files changed, 169 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0392b2b..d8538c5 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,16 @@ Security notes: - Replay protection is enforced by deduplicating `eventId` values in the ingestion service. - Duplicate deliveries are treated as safe no-ops and return `202 Accepted`. +## Soft Delete / Retention policy + +A new soft delete flow is available for stream records: + +- `DELETE /api/v1/streams/:id`: marks the record as soft-deleted via `deleted_at` timestamp. +- `GET /api/v1/streams`: by default returns non-deleted records; add `?includeDeleted=true` for admin/inspection mode. +- `GET /api/v1/streams/:id`: by default hides soft-deleted records; add `?includeDeleted=true` to retrieve them. + +All queries now include `deleted_at IS NULL` unless `includeDeleted` is explicitly true. + ## API Versioning Policy All new features and endpoints must be mounted under the `/api/v1` prefix. diff --git a/src/api/v1/streams.test.ts b/src/api/v1/streams.test.ts index 59a3907..e5d4a57 100644 --- a/src/api/v1/streams.test.ts +++ b/src/api/v1/streams.test.ts @@ -45,6 +45,44 @@ describe("Stream API Routes", () => { expect(response.status).toBe(400); expect(response.body.error).toBe("Invalid stream ID format"); }); + + it("should allow includeDeleted for get by id", async () => { + const mockStream = { id: validId, payer: "p1", accruedEstimate: "10.5" }; + const spy = jest.spyOn(StreamRepository.prototype, "findById").mockResolvedValue(mockStream as never); + + const response = await request(app) + .get(`/api/v1/streams/${validId}?includeDeleted=true`) + .set("x-api-key", "test-1234"); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockStream); + expect(spy).toHaveBeenCalledWith(validId, true); + spy.mockRestore(); + }); + + it("soft-deletes stream with DELETE", async () => { + const spy = jest.spyOn(StreamRepository.prototype, "softDeleteById").mockResolvedValue(true); + + const response = await request(app) + .delete(`/api/v1/streams/${validId}`) + .set("x-api-key", "test-1234"); + + expect(response.status).toBe(204); + expect(spy).toHaveBeenCalledWith(validId); + spy.mockRestore(); + }); + + it("returns 404 when delete target not found", async () => { + const spy = jest.spyOn(StreamRepository.prototype, "softDeleteById").mockResolvedValue(false); + + const response = await request(app) + .delete(`/api/v1/streams/${validId}`) + .set("x-api-key", "test-1234"); + + expect(response.status).toBe(404); + expect(response.body.error).toBe("Stream not found"); + spy.mockRestore(); + }); }); describe("GET /api/v1/streams", () => { diff --git a/src/api/v1/streams.ts b/src/api/v1/streams.ts index df1b234..b7c8e5a 100644 --- a/src/api/v1/streams.ts +++ b/src/api/v1/streams.ts @@ -15,7 +15,8 @@ router.get("/:id", async (req: Request, res: Response) => { return res.status(400).json({ error: "Invalid stream ID format" }); } - const stream = await streamRepository.findById(id); + const includeDeleted = req.query.includeDeleted === "true" || req.query.includeDeleted === "1"; + const stream = await streamRepository.findById(id, includeDeleted); if (!stream) { return res.status(404).json({ error: "Stream not found" }); @@ -28,17 +29,43 @@ router.get("/:id", async (req: Request, res: Response) => { } }); +// DELETE /api/v1/streams/:id (soft delete) +router.delete("/:id", async (req: Request, res: Response) => { + try { + const { id } = req.params; + + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRegex.test(id)) { + return res.status(400).json({ error: "Invalid stream ID format" }); + } + + const deleted = await streamRepository.softDeleteById(id); + + if (!deleted) { + return res.status(404).json({ error: "Stream not found" }); + } + + return res.status(204).send(); + } catch (error) { + console.error("Error deleting stream:", error); + res.status(500).json({ error: "Internal server error" }); + } +}); + // GET /api/v1/streams router.get("/", async (req: Request, res: Response) => { try { const { payer, recipient, status, limit, offset } = req.query; + const includeDeleted = req.query.includeDeleted === "true" || req.query.includeDeleted === "1"; + const params: FindAllParams = { payer: payer as string | undefined, recipient: recipient as string | undefined, status: status as FindAllParams["status"], limit: limit ? parseInt(limit as string, 10) : undefined, offset: offset ? parseInt(offset as string, 10) : undefined, + includeDeleted, }; const result = await streamRepository.findAll(params); diff --git a/src/db/schema.ts b/src/db/schema.ts index 9dce69f..cdd5acb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -12,6 +12,7 @@ export const streams = pgTable("streams", { endTime: timestamp("end_time"), totalAmount: decimal("total_amount", { precision: 20, scale: 9 }).notNull(), lastSettledAt: timestamp("last_settled_at").notNull().defaultNow(), + deletedAt: timestamp("deleted_at"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); diff --git a/src/repositories/streamRepository.test.ts b/src/repositories/streamRepository.test.ts index 93e008e..81caf1b 100644 --- a/src/repositories/streamRepository.test.ts +++ b/src/repositories/streamRepository.test.ts @@ -4,6 +4,7 @@ import { db } from "../db/index"; jest.mock("../db/index", () => ({ db: { select: jest.fn(), + update: jest.fn(), }, })); @@ -58,6 +59,53 @@ describe("StreamRepository", () => { expect(result).toBeNull(); }); + + it("should not return deleted stream by default", async () => { + (db.select as jest.Mock).mockReturnValue(createMockQuery([])); + + const result = await repository.findById("deleted-id"); + + expect(result).toBeNull(); + }); + + it("should include deleted when includeDeleted is true", async () => { + const mockStream = { + id: "deleted-id", + payer: "payer1", + recipient: "recipient1", + status: "paused", + ratePerSecond: "1", + startTime: new Date(), + endTime: null, + lastSettledAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: new Date(), + }; + + (db.select as jest.Mock).mockReturnValue(createMockQuery([mockStream])); + + const result = await repository.findById("deleted-id", true); + + expect(result).not.toBeNull(); + expect(result?.id).toBe("deleted-id"); + }); + + it("should mark stream deleted with softDeleteById", async () => { + const updateBuilder = { + set: jest.fn().mockReturnThis(), + where: jest.fn().mockResolvedValue(1), + }; + + (db.update as jest.Mock).mockReturnValue(updateBuilder); + + const deleted = await repository.softDeleteById("to-delete-id"); + + expect(deleted).toBe(true); + expect(db.update).toHaveBeenCalled(); + expect(updateBuilder.set).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }); + expect(updateBuilder.where).toHaveBeenCalled(); + }); }); describe("findAll", () => { @@ -67,14 +115,33 @@ describe("StreamRepository", () => { { id: "2", payer: "p1", status: "paused", createdAt: new Date() }, ]; + const firstQuery = createMockQuery(mockStreams); + const countQuery = createMockQuery([{ count: 2 }]); + (db.select as jest.Mock) - .mockReturnValueOnce(createMockQuery(mockStreams)) // for data - .mockReturnValueOnce(createMockQuery([{ count: 2 }])); // for count + .mockReturnValueOnce(firstQuery) + .mockReturnValueOnce(countQuery); const result = await repository.findAll({ payer: "p1" }); expect(result.streams).toHaveLength(2); expect(result.total).toBe(2); + expect(firstQuery.where).toHaveBeenCalled(); + expect(countQuery.where).toHaveBeenCalled(); + }); + + it("should query deleted rows only when includeDeleted true", async () => { + const firstQuery = createMockQuery([{ id: "1", payer: "p1" }]); + const countQuery = createMockQuery([{ count: 1 }]); + + (db.select as jest.Mock) + .mockReturnValueOnce(firstQuery) + .mockReturnValueOnce(countQuery); + + await repository.findAll({ payer: "p1", includeDeleted: true }); + + expect(firstQuery.where).toHaveBeenCalled(); + expect(countQuery.where).toHaveBeenCalled(); }); }); }); diff --git a/src/repositories/streamRepository.ts b/src/repositories/streamRepository.ts index 7455c1c..fcef2bd 100644 --- a/src/repositories/streamRepository.ts +++ b/src/repositories/streamRepository.ts @@ -8,14 +8,18 @@ export interface FindAllParams { status?: "active" | "paused" | "cancelled" | "completed"; limit?: number; offset?: number; + includeDeleted?: boolean; } export class StreamRepository { - async findById(id: string): Promise<(Stream & { accruedEstimate: string }) | null> { + async findById(id: string, includeDeleted = false): Promise<(Stream & { accruedEstimate: string }) | null> { + const conditions = [eq(streams.id, id)]; + if (!includeDeleted) conditions.push(sql`${streams.deletedAt} IS NULL`); + const [result] = await db .select() .from(streams) - .where(eq(streams.id, id)) + .where(and(...conditions)) .limit(1); if (!result) return null; @@ -37,6 +41,10 @@ export class StreamRepository { if (params.recipient) conditions.push(eq(streams.recipient, params.recipient)); if (params.status) conditions.push(eq(streams.status, params.status)); + if (!params.includeDeleted) { + conditions.push(sql`${streams.deletedAt} IS NULL`); + } + const query = db .select() .from(streams) @@ -61,6 +69,19 @@ export class StreamRepository { }; } + async softDeleteById(id: string): Promise { + const result = await db + .update(streams) + .set({ deletedAt: new Date() }) + .where(eq(streams.id, id)); + + if (typeof result === "number") { + return result > 0; + } + + return (result as { rowCount?: number }).rowCount ? (result as { rowCount: number }).rowCount > 0 : false; + } + private calculateAccruedEstimate(stream: Stream): number { if (stream.status !== "active") return 0;