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}
+
+ Cancel
+
+ {isPending ? 'Clearing…' : 'Clear funnel'}
+
+
+
+ );
+}
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.
- void query.refetch()}
- type="button"
- >
- {query.isFetching ? 'Refreshing…' : 'Refresh'}
-
+
+ { clearMutation.reset(); setIsConfirmingClear(true); }}
+ type="button"
+ >
+ Clear funnel
+
+ void query.refetch()}
+ type="button"
+ >
+ {query.isFetching ? 'Refreshing…' : 'Refresh'}
+
+
+ {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 }),
+ );
+ });
});