Add manual outreach funnel clearing

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:02:18 +01:00
1 parent 6b2e1bc380
commit 4798385d8b
16 files changed
+319 -17

No files matched your search

+33
View File
@@ -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)!;
+38
View File
@@ -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(
+37 -3
View File
@@ -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<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string): Promise<Response> {
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((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 }),
);
});
});