From 4798385d8b78e384a047edf3994f07662f985845 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Fri, 14 Aug 2026 09:02:18 +0100 Subject: [PATCH] Add manual outreach funnel clearing --- .../features/outreach/clear-funnel-dialog.tsx | 48 ++++++++++++++ .../src/features/outreach/outreach-api.ts | 17 ++++- .../src/features/outreach/outreach-page.tsx | 62 +++++++++++++++---- .../src/features/outreach/outreach.css | 1 + docs/api.md | 1 + docs/data-flow.md | 2 + docs/database.md | 1 + docs/frontend.md | 2 + docs/services.md | 2 +- packages/api-contracts/src/outreach.ts | 13 ++++ src/database/candidate-queue-repository.ts | 37 +++++++++++ src/web/api-catalog.ts | 14 +++++ src/web/routes/outreach.ts | 25 ++++++++ tests/candidate-queue.test.ts | 33 ++++++++++ tests/dashboard/app.test.tsx | 38 ++++++++++++ tests/web/outreach-route.test.ts | 40 +++++++++++- 16 files changed, 319 insertions(+), 17 deletions(-) create mode 100644 apps/dashboard/src/features/outreach/clear-funnel-dialog.tsx diff --git a/apps/dashboard/src/features/outreach/clear-funnel-dialog.tsx b/apps/dashboard/src/features/outreach/clear-funnel-dialog.tsx new file mode 100644 index 0000000..00803b9 --- /dev/null +++ b/apps/dashboard/src/features/outreach/clear-funnel-dialog.tsx @@ -0,0 +1,48 @@ +import { Modal } from '../../components/modal.js'; + +interface ClearFunnelDialogProps { + clearableCount: number; + sendingCount: number; + errorMessage?: string | null; + isPending: boolean; + onCancel(): void; + onConfirm(): void; +} + +export function ClearFunnelDialog({ + clearableCount, + sendingCount, + errorMessage = null, + isPending, + onCancel, + onConfirm, +}: ClearFunnelDialogProps) { + return ( + + Confirm queue action +

Clear the outreach funnel?

+

+ This will cancel {clearableCount} waiting, screening, prepared, or retrying + {clearableCount === 1 ? ' candidate' : ' candidates'}. Cleared candidates remain in the audit history and will + not be rediscovered. +

+ {sendingCount > 0 ? ( +

{sendingCount} in-flight {sendingCount === 1 ? 'send' : 'sends'} will be left alone because delivery may already be crossing the remote-send boundary.

+ ) : null} +

If the bot is running, discovery may begin filling the funnel again immediately.

+ {errorMessage ?

{errorMessage}

: null} +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/features/outreach/outreach-api.ts b/apps/dashboard/src/features/outreach/outreach-api.ts index 69b1f46..d36e434 100644 --- a/apps/dashboard/src/features/outreach/outreach-api.ts +++ b/apps/dashboard/src/features/outreach/outreach-api.ts @@ -1,4 +1,7 @@ -import { OutreachFunnelResponseSchema } from '../../../../../packages/api-contracts/src/index.js'; +import { + OutreachClearResponseSchema, + OutreachFunnelResponseSchema, +} from '../../../../../packages/api-contracts/src/index.js'; import { createDashboardRequest } from '../../api/request.js'; export function createOutreachApi(fetchImplementation?: typeof fetch) { @@ -16,6 +19,18 @@ export function createOutreachApi(fetchImplementation?: typeof fetch) { } return OutreachFunnelResponseSchema.parse(payload); }, + async clearFunnel() { + const response = await request('/api/outreach', { method: 'DELETE' }); + const payload: unknown = await response.json(); + if (!response.ok) { + const message = typeof payload === 'object' && payload !== null && 'error' in payload + && typeof payload.error === 'string' + ? payload.error + : `Could not clear outreach funnel (${response.status})`; + throw new Error(message); + } + return OutreachClearResponseSchema.parse(payload); + }, }; } diff --git a/apps/dashboard/src/features/outreach/outreach-page.tsx b/apps/dashboard/src/features/outreach/outreach-page.tsx index f6efa4c..1abd6b4 100644 --- a/apps/dashboard/src/features/outreach/outreach-page.tsx +++ b/apps/dashboard/src/features/outreach/outreach-page.tsx @@ -1,11 +1,12 @@ import { useEffect, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js'; import { AccountOwnerBadge } from '../../components/account-owner-badge.js'; import { OriginPostCard } from '../../components/origin-post.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js'; import { outreachApi } from './outreach-api.js'; +import { ClearFunnelDialog } from './clear-funnel-dialog.js'; import './outreach.css'; const STAGES: ReadonlyArray<{ @@ -121,11 +122,17 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: } export function OutreachPage() { + const queryClient = useQueryClient(); const query = useQuery({ queryKey: ['outreach', 'funnel'], queryFn: outreachApi.getFunnel, refetchInterval: 10_000, }); + const clearMutation = useMutation({ + mutationFn: outreachApi.clearFunnel, + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }), + }); + const [isConfirmingClear, setIsConfirmingClear] = useState(false); // Single client-side clock for every displayed offset: retry countdowns tick // every second while visible, everything else every 10 seconds. @@ -156,25 +163,46 @@ export function OutreachPage() { } const { data } = query; + const clearableCount = data.activeCount - data.stages.sending; + const clearResult = clearMutation.data; return (
Live candidate queue

Outreach funnel

Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.

- +
+ + +
+ {clearResult ? ( +
+ Cleared {clearResult.cleared.total} unsent {clearResult.cleared.total === 1 ? 'candidate' : 'candidates'}. + {clearResult.retainedSending > 0 + ? ` ${clearResult.retainedSending} in-flight ${clearResult.retainedSending === 1 ? 'send was' : 'sends were'} retained.` + : ''} +
+ ) : null} +
Current burn-down

{data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}

-
{data.outcomes.sent} sent{data.outcomes.rejected} screened out
+
{data.outcomes.sent} sent{data.outcomes.rejected} not sent
    {STAGES.map((stage) => ( @@ -185,7 +213,7 @@ export function OutreachPage() { ))}
-

Retrying posts loop back to Ready when their cooldown expires. Sent and screened-out totals cover the lifetime of this queue.

+

Retrying posts loop back to Ready when their cooldown expires. Sent and not-sent totals, including manually cleared candidates, cover the lifetime of this queue.

@@ -204,6 +232,16 @@ export function OutreachPage() { )}
+ {isConfirmingClear ? ( + { setIsConfirmingClear(false); clearMutation.reset(); }} + onConfirm={() => clearMutation.mutate(undefined, { onSuccess: () => setIsConfirmingClear(false) })} + sendingCount={data.stages.sending} + /> + ) : null}
); } diff --git a/apps/dashboard/src/features/outreach/outreach.css b/apps/dashboard/src/features/outreach/outreach.css index dac953c..ae2cdcc 100644 --- a/apps/dashboard/src/features/outreach/outreach.css +++ b/apps/dashboard/src/features/outreach/outreach.css @@ -1,4 +1,5 @@ .outreach-page { display: grid; gap: 1rem; } +.outreach-clear-notice { padding: .75rem 1rem; color: var(--accent); background: rgba(117, 214, 157, .08); border: 1px solid rgba(117, 214, 157, .3); border-radius: .7rem; } .outreach-runway { position: relative; overflow: hidden; padding: 1.2rem; background: #101a16; border: 1px solid #355044; border-radius: 1rem; } .outreach-runway::before { position: absolute; inset: 0; content: ""; pointer-events: none; background: radial-gradient(circle at 12% -20%, rgba(117, 214, 157, .16), transparent 34%); } .runway-heading { position: relative; display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 1.2rem; } diff --git a/docs/api.md b/docs/api.md index e979e94..2005a1e 100644 --- a/docs/api.md +++ b/docs/api.md @@ -108,6 +108,7 @@ an administrator credential. | `GET` | `/api/analytics/ai-usage` | Get OpenRouter cost and model reliability | read | no | | `GET` | `/api/attention` | Get operator attention items | read | no | | `GET` | `/api/outreach` | Get active outreach candidates and funnel stages | read | no | +| `DELETE` | `/api/outreach` | Cancel every unsent outreach candidate except in-flight sends | admin | yes | | `GET` | `/api/growth` | Get per-account comment growth state | read | no | | `GET` | `/api/growth/{accountId}` | Get one account's growth settings, communities and comment history | read | no | | `PATCH` | `/api/growth/{accountId}` | Enable, disable or re-pace comment growth for an account | admin | yes | diff --git a/docs/data-flow.md b/docs/data-flow.md index cd66964..b870e12 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -46,6 +46,8 @@ An account is eligible for new outreach only when its runtime is running, both i Matrix cooldowns retain the operation that triggered them. A room-creation or invitation limit pauses new outreach but does not suppress replies in existing rooms; a limit returned while sending into an existing room pauses those sends as well. +The operator can manually clear the unsent funnel from the authenticated dashboard. The repository atomically moves discovered, screening, ready, and retryable candidates to a terminal rejection with the reason `Cleared manually by operator`, releases their leases, and retains the audit rows so those candidates are not rediscovered. A `sending` lease is never changed because its remote Matrix outcome may already be in flight. Clearing does not change bot mode; running discovery may refill the funnel immediately. + Screening, retry promotion, dispatch, and the operator's active-queue view are ordered by the Reddit post creation time, newest first; source priority is only a tie-breaker. The dispatch worker leases the newest ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of: - `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent; diff --git a/docs/database.md b/docs/database.md index f235c97..d4cfcad 100644 --- a/docs/database.md +++ b/docs/database.md @@ -64,6 +64,7 @@ The special legacy-unassigned account exists only to preserve rows created befor 12. A growth comment interrupted while posting is rejected, never retried. The process cannot know whether the write reached Reddit, and a duplicate comment cannot be withdrawn. 13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally. 14. A browsing selection is persisted as `pending` before any vote request. It becomes `submitted` only after Reddit confirms the authenticated upvote, or `failed` with the safe error reason; unconfirmed selections are never retried. Browsing rows never contribute to adaptive comment-growth promotion. +15. Manual outreach clearing is a terminal audited transition, not deletion. It rejects discovered, screening, ready, and retryable candidates, releases their leases, and never changes a `sending` row whose remote outcome may be uncertain. 10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval. diff --git a/docs/frontend.md b/docs/frontend.md index 1fe2312..13e6460 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -34,6 +34,8 @@ The Monitoring settings section owns the optional physique-targeting toggle and Prepared cards in the authenticated outreach funnel expose the exact opener, grounded image analysis, attractiveness and physique scores, current target range, detected gender, strategy/tone/model metadata, and original source image. The live range is returned through the existing preparation field so cached dashboard bundles remain compatible during a rolling deployment. The durable queue still owns the historical preparation snapshot used for reconciliation. The source image uses the shared `OriginPostCard` privacy behavior: blurred initially, first click reveals it, and a later click opens the full-screen preview. Candidates from older persisted snapshots degrade cleanly when newer optional metadata is absent. +The funnel header also offers a destructive, confirmed **Clear funnel** action. Its dialog names the number of cancellable candidates, explains that audit rows remain and running discovery can refill the queue, and separately identifies any in-flight sends that cannot safely be cancelled. Success is reported from the authoritative API response before the funnel query refreshes. + ## State model - Remote state: TanStack Query snapshots parsed by shared Zod schemas. diff --git a/docs/services.md b/docs/services.md index 92f157f..fe00b14 100644 --- a/docs/services.md +++ b/docs/services.md @@ -28,7 +28,7 @@ This is an ownership map, not an API dump. Read the owning implementation and te | `services/strategy-selector.ts` | Weighted explore/exploit selection and experiment metadata | Separate image/chat strategy sections; respect mature response window and sample floor. | | `services/recipient-deduplicator.ts` | Global recipient history plus live reservation | Case-insensitive; release failures, commit successes, recover stale claims. | | `utils/rate-limiter.ts` | Per-account send spacing/hour limits/jitter | Increment only for confirmed sends; reply priority may use the same limiter. | -| `database/candidate-queue-repository.ts` | Durable candidate state machine, leases, retries, counts | Mutations require the expected worker lease; recover expired/interrupted work. | +| `database/candidate-queue-repository.ts` | Durable candidate state machine, leases, retries, counts, and manual clearing | Mutations require the expected worker lease; recover expired/interrupted work; manual clearing preserves sending leases and terminal audit rows. | ## Account growth diff --git a/packages/api-contracts/src/outreach.ts b/packages/api-contracts/src/outreach.ts index d29b4b3..086b7b6 100644 --- a/packages/api-contracts/src/outreach.ts +++ b/packages/api-contracts/src/outreach.ts @@ -71,6 +71,19 @@ export const OutreachFunnelResponseSchema = z.strictObject({ candidates: z.array(OutreachCandidateSchema), }); +export const OutreachClearResponseSchema = z.strictObject({ + success: z.literal(true), + cleared: z.strictObject({ + discovered: z.number().int().nonnegative(), + screening: z.number().int().nonnegative(), + ready: z.number().int().nonnegative(), + retryable: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), + }), + retainedSending: z.number().int().nonnegative(), +}); + export type OutreachCandidateStatus = z.infer; export type OutreachCandidate = z.infer; export type OutreachFunnelResponse = z.infer; +export type OutreachClearResponse = z.infer; diff --git a/src/database/candidate-queue-repository.ts b/src/database/candidate-queue-repository.ts index 5862e48..5d89899 100644 --- a/src/database/candidate-queue-repository.ts +++ b/src/database/candidate-queue-repository.ts @@ -55,6 +55,15 @@ export interface PhysiqueTargetingReconciliation { retained: number; } +export interface CandidateQueueClearResult { + discovered: number; + screening: number; + ready: number; + retryable: number; + total: number; + retainedSending: number; +} + const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable']; /** Global, durable funnel from Reddit listing discovery through outbound delivery. */ @@ -251,6 +260,34 @@ export class CandidateQueueRepository { `).run(now, now + sendingDelayMs, now).changes; } + /** + * Cancels every candidate that cannot already be crossing the remote-send + * boundary. Sending leases are deliberately retained because their Matrix + * outcome may be unknown while this transaction runs. + */ + clearUnsent(now: number = Date.now()): CandidateQueueClearResult { + return this.db.transaction(() => { + const statuses = ['discovered', 'screening', 'ready', 'retryable'] as const; + const counts = Object.fromEntries(statuses.map((status) => [status, 0])) as Record<(typeof statuses)[number], number>; + const rows = this.db.prepare(` + SELECT status, COUNT(*) AS count FROM candidate_queue + WHERE status IN (${statuses.map(() => '?').join(', ')}) + GROUP BY status + `).all(...statuses) as Array<{ status: (typeof statuses)[number]; count: number }>; + for (const row of rows) counts[row.status] = row.count; + const retainedSending = (this.db.prepare( + `SELECT COUNT(*) AS count FROM candidate_queue WHERE status = 'sending'`, + ).get() as { count: number }).count; + const changed = this.db.prepare(` + UPDATE candidate_queue + SET status = 'rejected', last_error = 'Cleared manually by operator', updated_at = ?, + lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL + WHERE status IN (${statuses.map(() => '?').join(', ')}) + `).run(now, ...statuses).changes; + return { ...counts, total: changed, retainedSending }; + })(); + } + /** Re-evaluates prepared image candidates that have not yet been leased for sending. */ reconcilePhysiqueTargeting( targeting: PhysiqueTargeting, diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts index 8419c7c..f458612 100644 --- a/src/web/api-catalog.ts +++ b/src/web/api-catalog.ts @@ -179,6 +179,20 @@ export const API_OPERATIONS: readonly ApiOperation[] = [ operation('getAiUsageAnalytics', 'GET', '/api/analytics/ai-usage', 'Get OpenRouter cost and model reliability', ['Analytics']), operation('getAttention', 'GET', '/api/attention', 'Get operator attention items', ['Operations']), operation('getOutreachFunnel', 'GET', '/api/outreach', 'Get active outreach candidates and funnel stages', ['Operations']), + operation('clearOutreachFunnel', 'DELETE', '/api/outreach', 'Cancel every unsent outreach candidate except in-flight sends', ['Operations'], { + access: 'admin', + responseSchema: z.strictObject({ + success: z.literal(true), + cleared: z.strictObject({ + discovered: z.number().int().nonnegative(), + screening: z.number().int().nonnegative(), + ready: z.number().int().nonnegative(), + retryable: z.number().int().nonnegative(), + total: z.number().int().nonnegative(), + }), + retainedSending: z.number().int().nonnegative(), + }), + }), operation('getGrowthOverview', 'GET', '/api/growth', 'Get per-account comment growth state', ['Growth'], { responseSchema: GrowthOverviewResponseSchema, }), diff --git a/src/web/routes/outreach.ts b/src/web/routes/outreach.ts index 3023169..5607f59 100644 --- a/src/web/routes/outreach.ts +++ b/src/web/routes/outreach.ts @@ -123,5 +123,30 @@ export function createOutreachRouter(ctx: WebContext): Router { res.json(response); }); + router.delete('/outreach', (_req, res) => { + const queue = ctx.services.candidateQueue; + if (!queue) { + res.status(503).json({ error: 'Outreach queue not initialized' }); + return; + } + + const result = queue.clearUnsent(); + const message = `Cleared ${result.total} unsent outreach candidate(s)` + + (result.retainedSending > 0 ? `; retained ${result.retainedSending} in-flight send(s)` : ''); + ctx.activity.push('info', message); + ctx.broadcaster.push('warn', message, result); + res.json({ + success: true, + cleared: { + discovered: result.discovered, + screening: result.screening, + ready: result.ready, + retryable: result.retryable, + total: result.total, + }, + retainedSending: result.retainedSending, + }); + }); + return router; } diff --git a/tests/candidate-queue.test.ts b/tests/candidate-queue.test.ts index 9d7cb07..eb2e371 100644 --- a/tests/candidate-queue.test.ts +++ b/tests/candidate-queue.test.ts @@ -164,6 +164,39 @@ describe('CandidateQueueRepository', () => { expect(queue.getStats().byStatus.discovered).toBe(1); }); + it('manually clears every unsent stage while retaining an in-flight send', () => { + queue.enqueue([post('sending', 'alice')], 'image', 1, 1_000); + const sendingScreen = queue.claimForScreening('screen-sending', 1_000, 1_001)!; + queue.markReady(sendingScreen.postId, 'screen-sending', prepared(sendingScreen.post), 1_002); + queue.claimReady('account-a', 'send-worker', 1_000, 1_003); + + queue.enqueue([post('retryable', 'bob')], 'image', 1, 1_004); + const retryScreen = queue.claimForScreening('screen-retry', 1_000, 1_005)!; + queue.markReady(retryScreen.postId, 'screen-retry', prepared(retryScreen.post), 1_006); + queue.claimReady('account-b', 'retry-worker', 1_000, 1_007); + queue.deferDelivery('retryable', 'retry-worker', 'rate limited', 2_000, 1_008); + + queue.enqueue([post('ready', 'carol')], 'image', 1, 1_009); + const readyScreen = queue.claimForScreening('screen-ready', 1_000, 1_010)!; + queue.markReady(readyScreen.postId, 'screen-ready', prepared(readyScreen.post), 1_011); + queue.enqueue([post('screening', 'dana')], 'image', 1, 1_012); + queue.claimForScreening('screen-active', 1_000, 1_013); + queue.enqueue([post('discovered', 'erin')], 'image', 1, 1_014); + + expect(queue.clearUnsent(1_020)).toEqual({ + discovered: 1, screening: 1, ready: 1, retryable: 1, total: 4, retainedSending: 1, + }); + expect(queue.listActive()).toEqual([ + expect.objectContaining({ postId: 'sending', status: 'sending', leaseOwner: 'send-worker' }), + ]); + expect(queue.markReady('screening', 'screen-active', prepared(post('screening', 'dana')), 1_021)).toBe(false); + expect(queue.markSent('sending', 'send-worker', 1_022)).toBe(true); + expect(queue.getStats()).toMatchObject({ byStatus: { rejected: 4, sent: 1 } }); + const reasons = db.prepare(`SELECT DISTINCT last_error FROM candidate_queue WHERE status = 'rejected'`) + .all() as Array<{ last_error: string }>; + expect(reasons).toEqual([{ last_error: 'Cleared manually by operator' }]); + }); + it('retries a rate-limited prepared candidate and preserves its generated message', () => { queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000); const screening = queue.claimForScreening('screen', 100, 1_001)!; diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx index 96bd149..4a08ab8 100644 --- a/tests/dashboard/app.test.tsx +++ b/tests/dashboard/app.test.tsx @@ -172,6 +172,44 @@ describe('dashboard foundation', () => { expect(sourceImage).not.toHaveClass('is-blurred'); }); + it('requires confirmation before clearing unsent outreach and preserves in-flight sends', async () => { + installSuccessfulApi(); + let cleared = false; + let deleteRequests = 0; + server.use( + http.get('/api/outreach', () => HttpResponse.json({ + generatedAt: Date.now(), + activeCount: cleared ? 1 : 3, + stages: { discovered: cleared ? 0 : 2, screening: 0, ready: 0, sending: 1, retryable: 0 }, + outcomes: { sent: 12, rejected: cleared ? 9 : 7 }, + oldestActiveAt: Date.now() - 60_000, + candidates: [], + })), + http.delete('/api/outreach', () => { + deleteRequests += 1; + cleared = true; + return HttpResponse.json({ + success: true, + cleared: { discovered: 2, screening: 0, ready: 0, retryable: 0, total: 2 }, + retainedSending: 1, + }); + }), + ); + renderDashboard('/outreach'); + const user = userEvent.setup(); + + await user.click(await screen.findByRole('button', { name: 'Clear funnel' })); + const dialog = screen.getByRole('dialog', { name: 'Clear the outreach funnel?' }); + expect(dialog).toHaveTextContent('cancel 2 waiting, screening, prepared, or retrying candidates'); + expect(dialog).toHaveTextContent('1 in-flight send will be left alone'); + expect(deleteRequests).toBe(0); + + await user.click(within(dialog).getByRole('button', { name: 'Clear funnel' })); + expect(await screen.findByRole('status')).toHaveTextContent('Cleared 2 unsent candidates. 1 in-flight send was retained.'); + await waitFor(() => expect(screen.getByRole('heading', { name: '1 active post' })).toBeInTheDocument()); + expect(deleteRequests).toBe(1); + }); + it('hydrates media filters from the routed URL', async () => { installSuccessfulApi(); server.use( diff --git a/tests/web/outreach-route.test.ts b/tests/web/outreach-route.test.ts index e8c1c32..7c05e64 100644 --- a/tests/web/outreach-route.test.ts +++ b/tests/web/outreach-route.test.ts @@ -1,18 +1,21 @@ import express from 'express'; import type { AddressInfo } from 'node:net'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { OutreachFunnelResponseSchema } from '../../packages/api-contracts/src/index.js'; +import { + OutreachClearResponseSchema, + OutreachFunnelResponseSchema, +} from '../../packages/api-contracts/src/index.js'; import type { WebContext } from '../../src/web/context.js'; import { createOutreachRouter } from '../../src/web/routes/outreach.js'; const servers: Array> = []; -async function request(app: express.Express, path: string): Promise { +async function request(app: express.Express, path: string, init?: RequestInit): Promise { const server = app.listen(0); servers.push(server); await new Promise((resolve) => server.once('listening', resolve)); const { port } = server.address() as AddressInfo; - return fetch(`http://127.0.0.1:${port}${path}`); + return fetch(`http://127.0.0.1:${port}${path}`, init); } afterEach(async () => { @@ -102,4 +105,35 @@ describe('outreach route', () => { expect(response.status).toBe(503); expect(await response.json()).toEqual({ error: 'Outreach queue not initialized' }); }); + + it('clears unsent candidates, reports retained sends, and records the operator action', async () => { + const clearUnsent = vi.fn(() => ({ + discovered: 2, screening: 1, ready: 3, retryable: 1, total: 7, retainedSending: 1, + })); + const activity = { push: vi.fn() }; + const broadcaster = { push: vi.fn() }; + const context = { + services: { candidateQueue: { clearUnsent } }, activity, broadcaster, + } as unknown as WebContext; + const app = express().use('/api', createOutreachRouter(context)); + + const response = await request(app, '/api/outreach', { method: 'DELETE' }); + const parsed = OutreachClearResponseSchema.parse(await response.json()); + + expect(response.status).toBe(200); + expect(parsed).toEqual({ + success: true, + cleared: { discovered: 2, screening: 1, ready: 3, retryable: 1, total: 7 }, + retainedSending: 1, + }); + expect(clearUnsent).toHaveBeenCalledOnce(); + expect(activity.push).toHaveBeenCalledWith( + 'info', 'Cleared 7 unsent outreach candidate(s); retained 1 in-flight send(s)', + ); + expect(broadcaster.push).toHaveBeenCalledWith( + 'warn', + 'Cleared 7 unsent outreach candidate(s); retained 1 in-flight send(s)', + expect.objectContaining({ total: 7, retainedSending: 1 }), + ); + }); });