Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions migrations/reports_index.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- up
-- Compound index for the scheduled reports list hot path.
-- The GET /api/reports/scheduled endpoint queries by user_id with ORDER BY created_at DESC.
-- This index covers both filter and sort in a single btree scan, avoiding a separate sort step.
CREATE INDEX IF NOT EXISTS scheduled_reports_user_created_at_idx
ON scheduled_reports (user_id, created_at DESC);

-- down
DROP INDEX IF EXISTS scheduled_reports_user_created_at_idx;
4 changes: 4 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,10 @@ export const scheduledReports = pgTable(
(t) => ({
scheduledReportsUserIdIdx: index("scheduled_reports_user_id_idx").on(t.userId),
scheduledReportsActiveIdx: index("scheduled_reports_active_idx").on(t.active),
scheduledReportsUserCreatedAtIdx: index("scheduled_reports_user_created_at_idx").on(
t.userId,
t.createdAt.desc(),
),
}),
);

Expand Down
27 changes: 23 additions & 4 deletions src/routes/reports/scheduled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

import { Router } from "express";
import { z } from "zod";
import { eq, desc } from "drizzle-orm";
import { eq, desc, and } from "drizzle-orm";
import { db } from "../../db";
import { scheduledReports } from "../../db/schema";
import { RouteErrorFactory } from "../../errors";
Expand Down Expand Up @@ -182,6 +182,20 @@ const listQuerySchema = z.object({
.refine((val) => val > 0 && val <= 100, {
message: "pageSize must be between 1 and 100",
}),
active: z
.string()
.optional()
.refine(
(val) => {
if (val === undefined || val === "") return true;
return ["true", "false", "1", "0"].includes(val);
},
{ message: "active must be 'true', 'false', '1', or '0'" },
)
.transform((val) => {
if (val === undefined || val === "") return undefined;
return val === "true" || val === "1";
}),
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -293,22 +307,27 @@ const listQuerySchema = z.object({
throw RouteErrorFactory.badRequest("Invalid query parameters");
}

const { page, pageSize } = parsed.data;
const { page, pageSize, active } = parsed.data;
const offset = (page - 1) * pageSize;

const whereConditions = [eq(scheduledReports.userId, userId)];
if (active !== undefined) {
whereConditions.push(eq(scheduledReports.active, active));
}

// Fetch total count for pagination metadata
const [countResult] = await db
.select({ count: db.$count(scheduledReports) })
.from(scheduledReports)
.where(eq(scheduledReports.userId, userId));
.where(and(...whereConditions));

const total = Number(countResult?.count ?? 0);

// Fetch paginated results
const results = await db
.select()
.from(scheduledReports)
.where(eq(scheduledReports.userId, userId))
.where(and(...whereConditions))
.orderBy(desc(scheduledReports.createdAt))
.limit(pageSize)
.offset(offset);
Expand Down
34 changes: 34 additions & 0 deletions tests/scheduledReports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,40 @@ describe("GET /api/reports/scheduled", () => {
expect(res.status).toBe(200);
expect(res.body.data).toEqual([]);
});

it("filters by active=true", async () => {
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:1}])})} as any);
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockReturnValueOnce({orderBy: jest.fn().mockReturnValueOnce({limit: jest.fn().mockReturnValueOnce({offset: jest.fn().mockResolvedValueOnce([])})})})})} as any);
const res = await request(app).get("/api/reports/scheduled").query({active:"true"});
expect(res.status).toBe(200);
});

it("filters by active=false", async () => {
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:1}])})} as any);
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockReturnValueOnce({orderBy: jest.fn().mockReturnValueOnce({limit: jest.fn().mockReturnValueOnce({offset: jest.fn().mockResolvedValueOnce([])})})})})} as any);
const res = await request(app).get("/api/reports/scheduled").query({active:"false"});
expect(res.status).toBe(200);
});

it("returns 400 when active invalid", async () => {
const res = await request(app).get("/api/reports/scheduled").query({active:"invalid"});
expect(res.status).toBe(400);
});

it("accepts active=1", async () => {
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:0}])})} as any);
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockReturnValueOnce({orderBy: jest.fn().mockReturnValueOnce({limit: jest.fn().mockReturnValueOnce({offset: jest.fn().mockResolvedValueOnce([])})})})})} as any);
const res = await request(app).get("/api/reports/scheduled").query({active:"1"});
expect(res.status).toBe(200);
});

it("accepts active=0", async () => {
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockResolvedValueOnce([{count:0}])})} as any);
mockDb.select.mockReturnValueOnce({from: jest.fn().mockReturnValueOnce({where: jest.fn().mockReturnValueOnce({orderBy: jest.fn().mockReturnValueOnce({limit: jest.fn().mockReturnValueOnce({offset: jest.fn().mockResolvedValueOnce([])})})})})} as any);
const res = await request(app).get("/api/reports/scheduled").query({active:"0"});
expect(res.status).toBe(200);
});

});

describe("GET /api/reports/scheduled/:id", () => {
Expand Down
Loading