diff --git a/client/src/hooks/use-campaigns.ts b/client/src/hooks/use-campaigns.ts index de6ef66..071317f 100644 --- a/client/src/hooks/use-campaigns.ts +++ b/client/src/hooks/use-campaigns.ts @@ -205,6 +205,37 @@ export function useSendCampaign() { }); } +// POST /api/admin/campaigns/recover +export function useRecoverCampaigns() { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation< + { recovered: number; campaigns: Array<{ id: number; name: string; subject: string; totalRecipients: number }>; message?: string }, + Error, + void + >({ + mutationFn: () => + fetchJson(`${CAMPAIGNS_KEY}/recover`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }), + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: [CAMPAIGNS_KEY] }); + queryClient.invalidateQueries({ queryKey: [DASHBOARD_KEY] }); + if (data.recovered > 0) { + toast({ title: "Campaigns recovered", description: `Recovered ${data.recovered} campaign(s) from recipient data.` }); + } else { + toast({ title: "No campaigns to recover", description: data.message || "No orphaned recipient data found." }); + } + }, + onError: (err: Error) => { + toast({ title: "Error", description: err.message, variant: "destructive" }); + }, + }); +} + // POST /api/admin/campaigns/:id/cancel export function useCancelCampaign() { const queryClient = useQueryClient(); diff --git a/client/src/pages/AdminCampaigns.tsx b/client/src/pages/AdminCampaigns.tsx index f28a2af..1e708ef 100644 --- a/client/src/pages/AdminCampaigns.tsx +++ b/client/src/pages/AdminCampaigns.tsx @@ -51,6 +51,7 @@ import { Loader2, Play, Pencil, + RotateCcw, } from "lucide-react"; import { Link, useLocation } from "wouter"; import DashboardNav from "@/components/DashboardNav"; @@ -66,6 +67,7 @@ import { useAutomatedCampaigns, useUpdateAutomatedCampaign, useTriggerAutomatedCampaign, + useRecoverCampaigns, } from "@/hooks/use-campaigns"; import type { Campaign, AutomatedCampaignConfig } from "@shared/schema"; @@ -697,6 +699,7 @@ export default function AdminCampaigns() { const { data: campaigns, isLoading } = useCampaigns(); const { data: dashboard } = useCampaignDashboard(); const deleteCampaign = useDeleteCampaign(); + const recoverCampaigns = useRecoverCampaigns(); return (
@@ -784,6 +787,20 @@ export default function AdminCampaigns() {

No campaigns yet

Create your first email campaign to start communicating with users.

+
) : ( diff --git a/server/routes.campaignRecover.test.ts b/server/routes.campaignRecover.test.ts new file mode 100644 index 0000000..d11e84b --- /dev/null +++ b/server/routes.campaignRecover.test.ts @@ -0,0 +1,529 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; + +const previousAppOwnerId = process.env.APP_OWNER_ID; + +// --------------------------------------------------------------------------- +// Hoisted mock variables +// --------------------------------------------------------------------------- +const { + mockGetUser, + mockDbExecute, + mockDbSelect, + mockLimitFn, + mockSelectWhereFn, + mockSelectFromFn, + mockOrderByFn, + mockGetResendClient, +} = vi.hoisted(() => { + const mockLimitFn = vi.fn(); + const mockOrderByFn = vi.fn(() => ({ limit: mockLimitFn })); + const mockSelectWhereFn = vi.fn(() => ({ limit: mockLimitFn, orderBy: mockOrderByFn })); + const mockSelectFromFn = vi.fn(() => ({ where: mockSelectWhereFn, orderBy: mockOrderByFn })); + const mockDbSelect = vi.fn(() => ({ from: mockSelectFromFn })); + const mockDbExecute = vi.fn(); + + return { + mockGetUser: vi.fn(), + mockDbExecute, + mockDbSelect, + mockLimitFn, + mockSelectWhereFn, + mockSelectFromFn, + mockOrderByFn, + mockGetResendClient: vi.fn(), + }; +}); + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- +vi.mock("./replit_integrations/auth", () => ({ + setupAuth: vi.fn().mockResolvedValue(undefined), + registerAuthRoutes: vi.fn(), + isAuthenticated: (_req: any, _res: any, next: any) => next(), +})); + +vi.mock("./replit_integrations/auth/storage", () => ({ + authStorage: { + getUser: (...args: any[]) => mockGetUser(...args), + }, +})); + +vi.mock("./storage", () => ({ + storage: { + getMonitor: vi.fn(), + getMonitors: vi.fn().mockResolvedValue([]), + getAllActiveMonitors: vi.fn().mockResolvedValue([]), + deleteMonitor: vi.fn(), + createMonitor: vi.fn(), + updateMonitor: vi.fn(), + }, +})); + +vi.mock("./db", () => ({ + db: { + select: (...args: any[]) => mockDbSelect(...args), + delete: vi.fn(() => ({ where: vi.fn() })), + insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([]) }) }), + update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue([]) })) })) })), + execute: (...args: any[]) => mockDbExecute(...args), + }, +})); + +vi.mock("./services/logger", () => ({ + ErrorLogger: { + error: vi.fn().mockResolvedValue(undefined), + warning: vi.fn().mockResolvedValue(undefined), + info: vi.fn().mockResolvedValue(undefined), + }, +})); + +vi.mock("./services/scraper", () => ({ + checkMonitor: vi.fn(), + extractWithBrowserless: vi.fn(), + detectPageBlockReason: vi.fn().mockReturnValue({ blocked: false }), + discoverSelectors: vi.fn(), + validateCssSelector: vi.fn(), + extractValueFromHtml: vi.fn(), +})); + +vi.mock("./stripeClient", () => ({ + getUncachableStripeClient: vi.fn(), + getStripePublishableKey: vi.fn().mockReturnValue("pk_test_123"), +})); + +vi.mock("./services/email", () => ({ + sendNotificationEmail: vi.fn(), +})); + +vi.mock("./services/browserlessTracker", () => ({ + BrowserlessUsageTracker: { getMonthlyUsage: vi.fn(), recordUsage: vi.fn() }, + getMonthResetDate: vi.fn().mockReturnValue("2026-03-01"), +})); + +vi.mock("./services/resendTracker", () => ({ + ResendUsageTracker: { recordSend: vi.fn() }, + getResendResetDate: vi.fn().mockReturnValue("2026-03-01"), +})); + +vi.mock("./middleware/rateLimiter", () => ({ + generalRateLimiter: (_req: any, _res: any, next: any) => next(), + createMonitorRateLimiter: (_req: any, _res: any, next: any) => next(), + checkMonitorRateLimiter: (_req: any, _res: any, next: any) => next(), + suggestSelectorsRateLimiter: (_req: any, _res: any, next: any) => next(), + emailUpdateRateLimiter: (_req: any, _res: any, next: any) => next(), + contactFormRateLimiter: (_req: any, _res: any, next: any) => next(), + unauthenticatedRateLimiter: (_req: any, _res: any, next: any) => next(), +})); + +vi.mock("./services/scheduler", () => ({ + startScheduler: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("./services/campaignEmail", () => ({ + sendTestCampaignEmail: vi.fn(), + previewRecipients: vi.fn().mockResolvedValue({ count: 0, users: [] }), + resolveRecipients: vi.fn().mockResolvedValue([]), + triggerCampaignSend: vi.fn().mockResolvedValue({ totalRecipients: 0 }), + cancelCampaign: vi.fn().mockResolvedValue({ sentSoFar: 0, cancelled: 0 }), +})); + +vi.mock("./services/resendClient", () => ({ + getResendClient: (...args: any[]) => mockGetResendClient(...args), +})); + +// --------------------------------------------------------------------------- +// Helpers: capture route handlers from Express mock +// --------------------------------------------------------------------------- +type RouteHandler = (req: any, res: any, next?: any) => Promise; +const registeredRoutes: Record> = {}; + +function makeMockApp() { + const makeRegistrar = (method: string) => (path: string, ...handlers: any[]) => { + if (!registeredRoutes[method]) registeredRoutes[method] = {}; + registeredRoutes[method][path] = handlers; + }; + return { + get: makeRegistrar("get"), + post: makeRegistrar("post"), + put: makeRegistrar("put"), + patch: makeRegistrar("patch"), + delete: makeRegistrar("delete"), + use: vi.fn(), + set: vi.fn(), + }; +} + +function makeRes() { + const res: any = { + _status: 200, + _json: null, + status(code: number) { res._status = code; return res; }, + json(body: any) { res._json = body; return res; }, + send(body: any) { res._body = body; return res; }, + }; + return res; +} + +async function callHandler(method: string, path: string, req: any) { + const handlers = registeredRoutes[method]?.[path]; + if (!handlers) throw new Error(`No handler for ${method.toUpperCase()} ${path}`); + const res = makeRes(); + const handler = handlers[handlers.length - 1]; + await handler(req, res); + return res; +} + +// --------------------------------------------------------------------------- +// Register routes once +// --------------------------------------------------------------------------- +let routesRegistered = false; + +async function ensureRoutes() { + if (routesRegistered) return; + process.env.APP_OWNER_ID = "owner-123"; + const { registerRoutes } = await import("./routes"); + const app = makeMockApp(); + const mockHttpServer = {} as any; + await registerRoutes(mockHttpServer, app as any); + routesRegistered = true; +} + +afterAll(() => { + if (previousAppOwnerId === undefined) { + delete process.env.APP_OWNER_ID; + } else { + process.env.APP_OWNER_ID = previousAppOwnerId; + } +}); + +// --------------------------------------------------------------------------- +// Tests: POST /api/admin/campaigns/recover +// --------------------------------------------------------------------------- +describe("POST /api/admin/campaigns/recover", () => { + const ENDPOINT = "/api/admin/campaigns/recover"; + + beforeEach(async () => { + await ensureRoutes(); + vi.clearAllMocks(); + + // Reset chain mocks + mockOrderByFn.mockReturnValue({ limit: mockLimitFn }); + mockSelectFromFn.mockReturnValue({ where: mockSelectWhereFn, orderBy: mockOrderByFn }); + mockDbSelect.mockReturnValue({ from: mockSelectFromFn }); + }); + + it("returns 401 when user is not authenticated", async () => { + const req = { user: null, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + expect(res._status).toBe(401); + expect(res._json).toEqual({ message: "Unauthorized" }); + }); + + it("returns 403 when user is not power tier", async () => { + mockGetUser.mockResolvedValue({ tier: "pro" }); + const req = { user: { claims: { sub: "user-1" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + expect(res._status).toBe(403); + expect(res._json).toEqual({ message: "Admin access required" }); + }); + + it("returns 403 when power user is not app owner", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + const req = { user: { claims: { sub: "not-the-owner" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + expect(res._status).toBe(403); + expect(res._json).toEqual({ message: "Owner access required" }); + }); + + it("returns zero recovered when no orphaned recipients exist", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + mockDbExecute.mockResolvedValue({ rows: [] }); + + const req = { user: { claims: { sub: "owner-123" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + + expect(res._status).toBe(200); + expect(res._json).toEqual({ + recovered: 0, + campaigns: [], + message: "No orphaned recipients found — campaign data appears intact.", + }); + }); + + it("recovers a campaign from orphaned recipient data without Resend client", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + mockGetResendClient.mockReturnValue(null); + + // Call sequence: 1=orphans, 2=stats, 3=INSERT, 4=setval + let callCount = 0; + mockDbExecute.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ rows: [{ campaign_id: 42 }] }); + } + if (callCount === 2) { + return Promise.resolve({ + rows: [{ + total: 10, + sent: 8, + failed: 2, + delivered: 6, + opened: 3, + clicked: 1, + first_sent: "2026-01-01T00:00:00Z", + last_sent: "2026-01-01T01:00:00Z", + }], + }); + } + if (callCount === 3) { + // INSERT + return Promise.resolve({ rows: [], rowCount: 1 }); + } + // setval + return Promise.resolve({ rows: [] }); + }); + + const req = { user: { claims: { sub: "owner-123" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + + expect(res._status).toBe(200); + expect(res._json.recovered).toBe(1); + expect(res._json.campaigns).toHaveLength(1); + expect(res._json.campaigns[0]).toEqual({ + id: 42, + name: "Recovered Campaign #42", + subject: "Recovered Campaign #42", + totalRecipients: 10, + }); + }); + + it("recovers subject and body from Resend API when available", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + const mockResend = { + emails: { + get: vi.fn().mockResolvedValue({ + data: { subject: "Original Subject", html: "

Original Body

" }, + }), + }, + }; + mockGetResendClient.mockReturnValue(mockResend); + + // Call sequence: 1=orphans, 2=stats, 3=sample resend_id, 4=INSERT, 5=setval + let callCount = 0; + mockDbExecute.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ rows: [{ campaign_id: 7 }] }); + } + if (callCount === 2) { + return Promise.resolve({ + rows: [{ + total: 5, sent: 5, failed: 0, delivered: 5, + opened: 2, clicked: 1, + first_sent: "2026-02-01T00:00:00Z", + last_sent: "2026-02-01T00:30:00Z", + }], + }); + } + if (callCount === 3) { + return Promise.resolve({ rows: [{ resend_id: "resend_abc123" }] }); + } + if (callCount === 4) { + // INSERT + return Promise.resolve({ rows: [], rowCount: 1 }); + } + // setval + return Promise.resolve({ rows: [] }); + }); + + const req = { user: { claims: { sub: "owner-123" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + + expect(res._status).toBe(200); + expect(res._json.recovered).toBe(1); + expect(res._json.campaigns[0].subject).toBe("Original Subject"); + expect(mockResend.emails.get).toHaveBeenCalledWith("resend_abc123"); + }); + + it("falls back to default subject when Resend API call fails", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + const mockResend = { + emails: { + get: vi.fn().mockRejectedValue(new Error("Resend API error")), + }, + }; + mockGetResendClient.mockReturnValue(mockResend); + + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Call sequence: 1=orphans, 2=stats, 3=sample resend_id, 4=INSERT, 5=setval + let callCount = 0; + mockDbExecute.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ rows: [{ campaign_id: 99 }] }); + } + if (callCount === 2) { + return Promise.resolve({ + rows: [{ + total: 3, sent: 3, failed: 0, delivered: 3, + opened: 1, clicked: 0, + first_sent: "2026-03-01T00:00:00Z", + last_sent: "2026-03-01T00:10:00Z", + }], + }); + } + if (callCount === 3) { + return Promise.resolve({ rows: [{ resend_id: "resend_fail" }] }); + } + if (callCount === 4) { + // INSERT + return Promise.resolve({ rows: [], rowCount: 1 }); + } + // setval + return Promise.resolve({ rows: [] }); + }); + + const req = { user: { claims: { sub: "owner-123" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + + expect(res._status).toBe(200); + expect(res._json.recovered).toBe(1); + expect(res._json.campaigns[0].subject).toBe("Recovered Campaign #99"); + warnSpy.mockRestore(); + }); + + it("recovers multiple campaigns in a single call", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + mockGetResendClient.mockReturnValue(null); + + let callCount = 0; + mockDbExecute.mockImplementation(() => { + callCount++; + if (callCount === 1) { + // Orphaned campaign IDs + return Promise.resolve({ rows: [{ campaign_id: 10 }, { campaign_id: 20 }] }); + } + if (callCount === 2) { + // Stats for campaign 10 + return Promise.resolve({ + rows: [{ total: 5, sent: 5, failed: 0, delivered: 5, opened: 2, clicked: 1, first_sent: "2026-01-01T00:00:00Z", last_sent: "2026-01-01T00:30:00Z" }], + }); + } + if (callCount === 3) { + // INSERT for campaign 10 + return Promise.resolve({ rows: [], rowCount: 1 }); + } + if (callCount === 4) { + // Stats for campaign 20 + return Promise.resolve({ + rows: [{ total: 3, sent: 2, failed: 1, delivered: 2, opened: 0, clicked: 0, first_sent: "2026-02-01T00:00:00Z", last_sent: "2026-02-01T00:15:00Z" }], + }); + } + if (callCount === 5) { + // INSERT for campaign 20 + return Promise.resolve({ rows: [], rowCount: 1 }); + } + // setval at end + return Promise.resolve({ rows: [] }); + }); + + const req = { user: { claims: { sub: "owner-123" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + + expect(res._status).toBe(200); + expect(res._json.recovered).toBe(2); + expect(res._json.campaigns).toHaveLength(2); + expect(res._json.campaigns[0].id).toBe(10); + expect(res._json.campaigns[1].id).toBe(20); + }); + + it("returns 500 when database throws an error", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + mockGetUser.mockResolvedValue({ tier: "power" }); + mockDbExecute.mockRejectedValue(new Error("DB connection lost")); + + const req = { user: { claims: { sub: "owner-123" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + + expect(res._status).toBe(500); + expect(res._json).toEqual({ message: "Failed to recover campaigns" }); + errorSpy.mockRestore(); + }); + + it("sets status to partially_sent when there are failures and not all done", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + mockGetResendClient.mockReturnValue(null); + + // Call sequence: 1=orphans, 2=stats, 3=INSERT, 4=setval + let callCount = 0; + mockDbExecute.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ rows: [{ campaign_id: 55 }] }); + } + if (callCount === 2) { + // failed > 0 and sent + failed !== total → partially_sent + return Promise.resolve({ + rows: [{ + total: 10, sent: 5, failed: 2, delivered: 4, + opened: 1, clicked: 0, + first_sent: "2026-01-15T00:00:00Z", + last_sent: "2026-01-15T00:30:00Z", + }], + }); + } + if (callCount === 3) { + // INSERT + return Promise.resolve({ rows: [], rowCount: 1 }); + } + // setval + return Promise.resolve({ rows: [] }); + }); + + const req = { user: { claims: { sub: "owner-123" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + + expect(res._status).toBe(200); + expect(res._json.recovered).toBe(1); + // We can't directly check the DB INSERT args since db.execute is a generic mock, + // but we verify the endpoint succeeded and returned the campaign + expect(res._json.campaigns[0].id).toBe(55); + }); + + it("skips already-recovered campaigns (idempotent retry)", async () => { + mockGetUser.mockResolvedValue({ tier: "power" }); + mockGetResendClient.mockReturnValue(null); + + let callCount = 0; + mockDbExecute.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ rows: [{ campaign_id: 42 }] }); + } + if (callCount === 2) { + return Promise.resolve({ + rows: [{ + total: 10, sent: 10, failed: 0, delivered: 10, + opened: 5, clicked: 2, + first_sent: "2026-01-01T00:00:00Z", + last_sent: "2026-01-01T01:00:00Z", + }], + }); + } + if (callCount === 3) { + // INSERT returns rowCount: 0 (already exists via ON CONFLICT DO NOTHING) + return Promise.resolve({ rows: [], rowCount: 0 }); + } + return Promise.resolve({ rows: [] }); + }); + + const req = { user: { claims: { sub: "owner-123" } }, body: {} }; + const res = await callHandler("post", ENDPOINT, req); + + expect(res._status).toBe(200); + expect(res._json.recovered).toBe(0); + expect(res._json.campaigns).toHaveLength(0); + }); +}); diff --git a/server/routes.ts b/server/routes.ts index 20673fe..4ebbf79 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -2309,6 +2309,141 @@ export async function registerRoutes( } }); + // Recover campaigns whose rows were lost but whose recipients still exist. + // Uses Resend API to fetch subject/body from a sample recipient's resend_id, + // then reconstructs the campaign row with accurate counters. + app.post("/api/admin/campaigns/recover", isAuthenticated, async (req: any, res) => { + try { + const userId = req.user?.claims?.sub; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const user = await authStorage.getUser(userId); + if (!user || user.tier !== "power") return res.status(403).json({ message: "Admin access required" }); + if (userId !== APP_OWNER_ID) return res.status(403).json({ message: "Owner access required" }); + + // Find campaign IDs referenced by recipients but missing from campaigns table + const orphanRows = await db.execute(sql` + SELECT DISTINCT cr.campaign_id + FROM campaign_recipients cr + LEFT JOIN campaigns c ON c.id = cr.campaign_id + WHERE c.id IS NULL + `); + + const orphanedIds = (orphanRows.rows as { campaign_id: number }[]).map(r => r.campaign_id); + if (orphanedIds.length === 0) { + return res.json({ recovered: 0, campaigns: [], message: "No orphaned recipients found — campaign data appears intact." }); + } + + const resend = getResendClient(); + const recovered: Array<{ id: number; name: string; subject: string; totalRecipients: number }> = []; + + for (const campaignId of orphanedIds) { + // Aggregate counters from recipient rows + const statsResult = await db.execute(sql` + SELECT + COUNT(*)::int AS total, + COUNT(*) FILTER (WHERE status IN ('sent','delivered','opened','clicked'))::int AS sent, + COUNT(*) FILTER (WHERE status = 'failed' OR status = 'bounced' OR status = 'complained')::int AS failed, + COUNT(*) FILTER (WHERE status IN ('delivered','opened','clicked'))::int AS delivered, + COUNT(*) FILTER (WHERE status IN ('opened','clicked'))::int AS opened, + COUNT(*) FILTER (WHERE status = 'clicked')::int AS clicked, + MIN(sent_at) AS first_sent, + MAX(sent_at) AS last_sent + FROM campaign_recipients + WHERE campaign_id = ${campaignId} + `); + + const stats = statsResult.rows[0] as any; + + // Try to recover subject/body from Resend API using a sample resend_id + let subject = `Recovered Campaign #${campaignId}`; + let htmlBody = "

(Email body could not be recovered)

"; + + if (resend) { + const sampleRow = await db.execute(sql` + SELECT resend_id FROM campaign_recipients + WHERE campaign_id = ${campaignId} AND resend_id IS NOT NULL + LIMIT 1 + `); + + const sampleResendId = (sampleRow.rows[0] as { resend_id: string } | undefined)?.resend_id; + if (sampleResendId) { + try { + const emailData = await resend.emails.get(sampleResendId); + if (emailData.data) { + subject = emailData.data.subject ?? subject; + htmlBody = emailData.data.html ?? htmlBody; + } + } catch (e) { + console.warn(`[CampaignRecover] Could not fetch sample email for campaign ${campaignId}:`, e instanceof Error ? e.message : "unknown error"); + } + } + } + + // Determine status from counters + const sentCount = Number(stats.sent); + const failedCount = Number(stats.failed); + const totalCount = Number(stats.total); + let status: string; + if (sentCount === 0 && failedCount === 0) { + status = "draft"; + } else if (failedCount > 0) { + status = "partially_sent"; + } else { + status = "sent"; + } + + // Re-insert the campaign row with its original ID using raw SQL + // so that the foreign key from campaign_recipients is satisfied again. + // ON CONFLICT DO NOTHING makes retries safe if a prior attempt partially completed. + const insertResult = await db.execute(sql` + INSERT INTO campaigns (id, name, subject, html_body, status, type, total_recipients, + sent_count, failed_count, delivered_count, opened_count, clicked_count, + created_at, sent_at, completed_at) + VALUES ( + ${campaignId}, + ${`Recovered Campaign #${campaignId}`}, + ${subject}, + ${htmlBody}, + ${status}, + 'manual', + ${Number(stats.total)}, + ${Number(stats.sent)}, + ${Number(stats.failed)}, + ${Number(stats.delivered)}, + ${Number(stats.opened)}, + ${Number(stats.clicked)}, + COALESCE(${stats.first_sent}::timestamptz, NOW()), + ${stats.first_sent ?? null}::timestamptz, + ${stats.last_sent ?? null}::timestamptz + ) + ON CONFLICT (id) DO NOTHING + `); + + // Skip if already recovered (idempotent retry) + if (insertResult.rowCount === 0) continue; + + recovered.push({ + id: campaignId, + name: `Recovered Campaign #${campaignId}`, + subject, + totalRecipients: Number(stats.total), + }); + } + + // Advance the serial sequence past all recovered IDs so future inserts don't collide + if (recovered.length > 0) { + await db.execute(sql` + SELECT setval('campaigns_id_seq', (SELECT MAX(id) FROM campaigns)) + `); + } + + res.json({ recovered: recovered.length, campaigns: recovered }); + } catch (error: any) { + console.error("Error recovering campaigns:", error instanceof Error ? error.message : "unknown error"); + res.status(500).json({ message: "Failed to recover campaigns" }); + } + }); + // Public unsubscribe endpoint (no auth required) // GET shows a confirmation page; POST performs the actual unsubscribe. // This prevents link prefetchers / email scanners from triggering unsubscribes.