diff --git a/client/src/pages/AdminErrors.tsx b/client/src/pages/AdminErrors.tsx index e0bdb93..909fcd2 100644 --- a/client/src/pages/AdminErrors.tsx +++ b/client/src/pages/AdminErrors.tsx @@ -155,7 +155,7 @@ export default function AdminErrors() { }, []); const finalizeMutation = useMutation({ - mutationFn: async () => { + mutationFn: async (): Promise<{ count: number; hasMore?: boolean }> => { const res = await fetch("/api/admin/error-logs/finalize", { method: "POST", credentials: "include", @@ -163,9 +163,12 @@ export default function AdminErrors() { if (!res.ok) throw new Error("Failed to finalize deletion"); return res.json(); }, - onSuccess: () => { + onSuccess: (data) => { invalidateAll(); clearSelection(); + if (data.hasMore) { + toast({ title: "More entries remain", description: "Some soft-deleted entries were not finalized. Repeat to finalize more." }); + } }, onError: () => { toast({ title: "Error", description: "Failed to permanently delete entries", variant: "destructive" }); @@ -173,7 +176,7 @@ export default function AdminErrors() { }); const restoreMutation = useMutation({ - mutationFn: async () => { + mutationFn: async (): Promise<{ count: number; hasMore?: boolean }> => { const res = await fetch("/api/admin/error-logs/restore", { method: "POST", credentials: "include", @@ -181,8 +184,11 @@ export default function AdminErrors() { if (!res.ok) throw new Error("Failed to restore entries"); return res.json(); }, - onSuccess: () => { + onSuccess: (data) => { invalidateAll(); + if (data.hasMore) { + toast({ title: "More entries remain", description: "Some soft-deleted entries were not restored. Repeat to restore more." }); + } }, onError: () => { toast({ title: "Error", description: "Failed to restore entries", variant: "destructive" }); @@ -247,12 +253,15 @@ export default function AdminErrors() { const err = await res.json().catch(() => null); throw new Error(err?.message || "Failed to delete entries"); } - return res.json() as Promise<{ count: number }>; + return res.json() as Promise<{ count: number; hasMore?: boolean }>; }, onSuccess: (data) => { invalidateAll(); clearSelection(); showUndoToast(data.count); + if (data.hasMore) { + toast({ title: "More entries remain", description: "Some matching entries were not deleted. Repeat to delete more." }); + } }, onError: (error: Error) => { toast({ title: "Error", description: error.message, variant: "destructive" }); diff --git a/server/routes.deleteErrorLog.test.ts b/server/routes.deleteErrorLog.test.ts index d430482..4742089 100644 --- a/server/routes.deleteErrorLog.test.ts +++ b/server/routes.deleteErrorLog.test.ts @@ -490,11 +490,15 @@ describe("POST /api/admin/error-logs/batch-delete", () => { await ensureRoutes(); vi.clearAllMocks(); - // For batch endpoints, the select chain ends at .where() (no .limit/.orderBy) - // so mockSelectWhereFn must resolve to data directly. + // For batch endpoints: + // - ID path: .where() resolves directly (no .limit/.orderBy) + // - Filter path: .where().orderBy().limit(500) + // Default to filter chain; ID-based tests override with mockResolvedValue. + mockLimitFn.mockResolvedValue([]); + mockOrderByFn.mockReturnValue({ limit: mockLimitFn }); + mockSelectWhereFn.mockReturnValue({ orderBy: mockOrderByFn, limit: mockLimitFn }); mockSelectFromFn.mockReturnValue({ where: mockSelectWhereFn }); mockDbSelect.mockReturnValue({ from: mockSelectFromFn }); - mockSelectWhereFn.mockResolvedValue([]); mockUpdateWhereFn.mockResolvedValue(undefined); mockUpdateSetFn.mockReturnValue({ where: mockUpdateWhereFn }); @@ -607,7 +611,7 @@ describe("POST /api/admin/error-logs/batch-delete", () => { it("soft-deletes entries matching filters for app owner", async () => { mockGetUser.mockResolvedValue({ tier: "power" }); mockGetMonitors.mockResolvedValue([]); - mockSelectWhereFn.mockResolvedValue([ + mockLimitFn.mockResolvedValue([ { id: 1, context: null }, { id: 2, context: null }, { id: 3, context: null }, @@ -616,14 +620,14 @@ describe("POST /api/admin/error-logs/batch-delete", () => { const req = { user: { claims: { sub: "owner-123" } }, body: { filters: { level: "error" } } }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "3 entries deleted", count: 3 }); + expect(res._json).toEqual({ message: "3 entries deleted", count: 3, hasMore: false }); expect(mockDbUpdate).toHaveBeenCalled(); }); it("soft-deletes with filter and excludeIds", async () => { mockGetUser.mockResolvedValue({ tier: "power" }); mockGetMonitors.mockResolvedValue([]); - mockSelectWhereFn.mockResolvedValue([ + mockLimitFn.mockResolvedValue([ { id: 1, context: null }, { id: 3, context: null }, ]); @@ -634,7 +638,7 @@ describe("POST /api/admin/error-logs/batch-delete", () => { }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "2 entries deleted", count: 2 }); + expect(res._json).toEqual({ message: "2 entries deleted", count: 2, hasMore: false }); }); it("rejects empty filters object", async () => { @@ -659,7 +663,7 @@ describe("POST /api/admin/error-logs/batch-delete", () => { it("applies ownership filtering with filters mode", async () => { mockGetUser.mockResolvedValue({ tier: "power" }); mockGetMonitors.mockResolvedValue([{ id: 10 }]); - mockSelectWhereFn.mockResolvedValue([ + mockLimitFn.mockResolvedValue([ { id: 1, context: { monitorId: 10 } }, { id: 2, context: null }, ]); @@ -667,7 +671,21 @@ describe("POST /api/admin/error-logs/batch-delete", () => { const req = { user: { claims: { sub: "not-the-owner" } }, body: { filters: { level: "error" } } }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "1 entries deleted", count: 1 }); + expect(res._json).toEqual({ message: "1 entries deleted", count: 1, hasMore: false }); + }); + + it("returns hasMore true when filter query hits the 500-row limit", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + mockGetMonitors.mockResolvedValue([]); + // Simulate exactly 500 rows returned (the limit) + const entries = Array.from({ length: 500 }, (_, i) => ({ id: i + 1, context: null })); + mockLimitFn.mockResolvedValue(entries); + + const req = { user: { claims: { sub: "owner-123" } }, body: { filters: { level: "error" } } }; + const res = await callHandler("post", ENDPOINT, req); + expect(res._status).toBe(200); + expect(res._json.count).toBe(500); + expect(res._json.hasMore).toBe(true); }); it("rejects filters with only invalid values", async () => { @@ -758,9 +776,10 @@ describe("POST /api/admin/error-logs/restore", () => { await ensureRoutes(); vi.clearAllMocks(); - // restore uses .where(...).limit(500), so chain through mockLimitFn + // restore uses .where(...).orderBy(...).limit(500), so chain through mockOrderByFn/mockLimitFn mockLimitFn.mockResolvedValue([]); - mockSelectWhereFn.mockReturnValue({ limit: mockLimitFn }); + mockOrderByFn.mockReturnValue({ limit: mockLimitFn }); + mockSelectWhereFn.mockReturnValue({ orderBy: mockOrderByFn, limit: mockLimitFn }); mockSelectFromFn.mockReturnValue({ where: mockSelectWhereFn }); mockDbSelect.mockReturnValue({ from: mockSelectFromFn }); @@ -795,7 +814,7 @@ describe("POST /api/admin/error-logs/restore", () => { const req = { user: { claims: { sub: "owner-123" } }, body: {} }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "2 entries restored", count: 2 }); + expect(res._json).toEqual({ message: "2 entries restored", count: 2, hasMore: false }); expect(mockDbUpdate).toHaveBeenCalled(); expect(mockUpdateSetFn).toHaveBeenCalled(); }); @@ -812,7 +831,7 @@ describe("POST /api/admin/error-logs/restore", () => { const req = { user: { claims: { sub: "not-the-owner" } }, body: {} }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "1 entries restored", count: 1 }); + expect(res._json).toEqual({ message: "1 entries restored", count: 1, hasMore: false }); }); it("returns count 0 when no soft-deleted entries exist", async () => { @@ -823,7 +842,7 @@ describe("POST /api/admin/error-logs/restore", () => { const req = { user: { claims: { sub: "owner-123" } }, body: {} }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "0 entries restored", count: 0 }); + expect(res._json).toEqual({ message: "0 entries restored", count: 0, hasMore: false }); expect(mockDbUpdate).not.toHaveBeenCalled(); }); @@ -851,9 +870,10 @@ describe("POST /api/admin/error-logs/finalize", () => { await ensureRoutes(); vi.clearAllMocks(); - // finalize uses .where(...).limit(500), so chain through mockLimitFn + // finalize uses .where(...).orderBy(...).limit(500), so chain through mockOrderByFn/mockLimitFn mockLimitFn.mockResolvedValue([]); - mockSelectWhereFn.mockReturnValue({ limit: mockLimitFn }); + mockOrderByFn.mockReturnValue({ limit: mockLimitFn }); + mockSelectWhereFn.mockReturnValue({ orderBy: mockOrderByFn, limit: mockLimitFn }); mockSelectFromFn.mockReturnValue({ where: mockSelectWhereFn }); mockDbSelect.mockReturnValue({ from: mockSelectFromFn }); @@ -887,7 +907,7 @@ describe("POST /api/admin/error-logs/finalize", () => { const req = { user: { claims: { sub: "owner-123" } }, body: {} }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "2 entries finalized", count: 2 }); + expect(res._json).toEqual({ message: "2 entries finalized", count: 2, hasMore: false }); expect(mockDbDelete).toHaveBeenCalled(); expect(mockDeleteWhereFn).toHaveBeenCalled(); }); @@ -904,7 +924,7 @@ describe("POST /api/admin/error-logs/finalize", () => { const req = { user: { claims: { sub: "not-the-owner" } }, body: {} }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "1 entries finalized", count: 1 }); + expect(res._json).toEqual({ message: "1 entries finalized", count: 1, hasMore: false }); }); it("returns count 0 when no soft-deleted entries exist", async () => { @@ -915,7 +935,7 @@ describe("POST /api/admin/error-logs/finalize", () => { const req = { user: { claims: { sub: "owner-123" } }, body: {} }; const res = await callHandler("post", ENDPOINT, req); expect(res._status).toBe(200); - expect(res._json).toEqual({ message: "0 entries finalized", count: 0 }); + expect(res._json).toEqual({ message: "0 entries finalized", count: 0, hasMore: false }); expect(mockDbDelete).not.toHaveBeenCalled(); }); @@ -932,3 +952,11 @@ describe("POST /api/admin/error-logs/finalize", () => { errorSpy.mockRestore(); }); }); + +describe("POST /api/test-email", () => { + it("is registered as POST, not GET", async () => { + await ensureRoutes(); + expect(registeredRoutes["post"]?.["/api/test-email"]).toBeDefined(); + expect(registeredRoutes["get"]?.["/api/test-email"]).toBeUndefined(); + }); +}); diff --git a/server/routes.ts b/server/routes.ts index 12d3569..20673fe 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -12,7 +12,7 @@ import { TIER_LIMITS, TAG_LIMITS, TAG_ASSIGNMENT_LIMITS, BROWSERLESS_CAPS, RESEN import { startScheduler } from "./services/scheduler"; import * as cheerio from "cheerio"; import { getUncachableStripeClient, getStripePublishableKey } from "./stripeClient"; -import { sql, desc, eq, and, isNull, isNotNull, inArray, notInArray } from "drizzle-orm"; +import { sql, asc, desc, eq, and, isNull, isNotNull, inArray, notInArray } from "drizzle-orm"; import { db } from "./db"; import { sendNotificationEmail } from "./services/email"; import { ErrorLogger } from "./services/logger"; @@ -217,7 +217,7 @@ export async function registerRoutes( }); // Test Email Endpoint - verifies Resend email delivery - app.get("/api/test-email", isAuthenticated, emailUpdateRateLimiter, async (req: any, res) => { + app.post("/api/test-email", isAuthenticated, emailUpdateRateLimiter, async (req: any, res) => { try { const userId = req.user.claims.sub; const user = await authStorage.getUser(userId); @@ -1614,7 +1614,7 @@ export async function registerRoutes( conditions.push(notInArray(errorLogs.id, excludeList)); } - const entries = await db.select().from(errorLogs).where(and(...conditions)); + const entries = await db.select().from(errorLogs).where(and(...conditions)).orderBy(asc(errorLogs.id)).limit(500); const authorized = entries.filter((log: any) => { const ctx = log.context as Record | null; @@ -1627,7 +1627,8 @@ export async function registerRoutes( const authorizedIds = authorized.map((e: any) => e.id); await db.update(errorLogs).set({ deletedAt: now }).where(inArray(errorLogs.id, authorizedIds)); } - res.json({ message: `${authorized.length} entries deleted`, count: authorized.length }); + const hasMore = entries.length === 500; + res.json({ message: `${authorized.length} entries deleted`, count: authorized.length, hasMore }); } } catch (error: any) { console.error("Error batch deleting error logs:", error); @@ -1652,7 +1653,7 @@ export async function registerRoutes( (await storage.getMonitors(userId)).map((m: any) => m.id) ); - const softDeleted = await db.select().from(errorLogs).where(isNotNull(errorLogs.deletedAt)).limit(500); + const softDeleted = await db.select().from(errorLogs).where(isNotNull(errorLogs.deletedAt)).orderBy(asc(errorLogs.id)).limit(500); const authorized = softDeleted.filter((log: any) => { const ctx = log.context as Record | null; @@ -1665,7 +1666,8 @@ export async function registerRoutes( const authorizedIds = authorized.map((e: any) => e.id); await db.update(errorLogs).set({ deletedAt: null }).where(inArray(errorLogs.id, authorizedIds)); } - res.json({ message: `${authorized.length} entries restored`, count: authorized.length }); + const hasMore = softDeleted.length === 500; + res.json({ message: `${authorized.length} entries restored`, count: authorized.length, hasMore }); } catch (error: any) { console.error("Error restoring error logs:", error); res.status(500).json({ message: "Failed to restore error logs" }); @@ -1689,7 +1691,7 @@ export async function registerRoutes( (await storage.getMonitors(userId)).map((m: any) => m.id) ); - const softDeleted = await db.select().from(errorLogs).where(isNotNull(errorLogs.deletedAt)).limit(500); + const softDeleted = await db.select().from(errorLogs).where(isNotNull(errorLogs.deletedAt)).orderBy(asc(errorLogs.id)).limit(500); const authorized = softDeleted.filter((log: any) => { const ctx = log.context as Record | null; @@ -1702,7 +1704,8 @@ export async function registerRoutes( const authorizedIds = authorized.map((e: any) => e.id); await db.delete(errorLogs).where(inArray(errorLogs.id, authorizedIds)); } - res.json({ message: `${authorized.length} entries finalized`, count: authorized.length }); + const hasMore = softDeleted.length === 500; + res.json({ message: `${authorized.length} entries finalized`, count: authorized.length, hasMore }); } catch (error: any) { console.error("Error finalizing error logs:", error); res.status(500).json({ message: "Failed to finalize error logs" });