Add outreach candidate editing controls

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:12:54 +01:00
1 parent c75e71f26e
commit 79c508be2b
19 files changed
+434 -12

No files matched your search

+44
View File
@@ -197,6 +197,50 @@ describe('CandidateQueueRepository', () => {
expect(reasons).toEqual([{ last_error: 'Cleared manually by operator' }]);
});
it('edits only prepared unsent messages and clears generated attribution', () => {
queue.enqueue([post('editable', 'alice', 200), post('waiting', 'bob', 100)], 'image', 1, 1_000);
const editable = queue.claimForScreening('screen', 1_000, 1_001)!;
queue.markReady(editable.postId, 'screen', prepared(editable.post), 1_002);
db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, generated_message, processed_at)
VALUES ('account-a', ?, 'test', 'alice', 'title', 'prepared message', 1)
`).run(editable.postId);
expect(queue.updatePreparedMessage(editable.postId, 'Operator-written opener', 1_003)).toBe('updated');
expect(queue.listActive().find((candidate) => candidate.postId === editable.postId)?.prepared).toMatchObject({
generatedMessage: 'Operator-written opener',
strategyId: null,
experimentId: null,
promptVersion: null,
selectionReason: null,
modelId: 'operator-edited',
operatorEdited: true,
});
expect((db.prepare(`SELECT generated_message FROM processed_posts WHERE id = ?`).get(editable.postId) as {
generated_message: string;
}).generated_message).toBe('Operator-written opener');
expect(queue.updatePreparedMessage('waiting', 'Nope', 1_004)).toBe('not-editable');
expect(queue.updatePreparedMessage('missing', 'Nope', 1_004)).toBe('not-found');
queue.claimReady('account-a', 'send', 1_000, 1_005);
expect(queue.updatePreparedMessage(editable.postId, 'Too late', 1_006)).toBe('not-editable');
});
it('removes one unsent candidate but refuses to cancel an in-flight send', () => {
queue.enqueue([post('remove', 'alice')], 'image', 1, 1_000);
expect(queue.cancelUnsent('remove', 1_001)).toBe('cancelled');
expect(queue.getStats()).toMatchObject({ byStatus: { rejected: 1 } });
expect(queue.cancelUnsent('remove', 1_002)).toBe('not-cancellable');
expect(queue.cancelUnsent('missing', 1_002)).toBe('not-found');
queue.enqueue([post('sending', 'bob')], 'image', 1, 1_003);
const candidate = queue.claimForScreening('screen', 1_000, 1_004)!;
queue.markReady(candidate.postId, 'screen', prepared(candidate.post), 1_005);
queue.claimReady('account-a', 'send', 1_000, 1_006);
expect(queue.cancelUnsent('sending', 1_007)).toBe('not-cancellable');
expect(queue.listActive()).toEqual([expect.objectContaining({ postId: 'sending', status: 'sending' })]);
});
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)!;
+28
View File
@@ -116,6 +116,8 @@ describe('dashboard foundation', () => {
it('shows the live outreach stages and the potential posts being burned down', async () => {
installSuccessfulApi();
const user = userEvent.setup();
let editedMessage: string | null = null;
let removedCandidate: string | null = null;
server.use(
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
http.get('/api/chats/sync', () => HttpResponse.json({
@@ -148,6 +150,15 @@ describe('dashboard foundation', () => {
note: null, owner: null,
}],
})),
http.patch('/api/outreach/:postId/message', async ({ params, request }) => {
const body = await request.json() as { message: string };
editedMessage = body.message;
return HttpResponse.json({ success: true, postId: params.postId, message: body.message });
}),
http.delete('/api/outreach/:postId', ({ params }) => {
removedCandidate = String(params.postId);
return HttpResponse.json({ success: true, postId: params.postId });
}),
);
renderDashboard('/outreach');
@@ -170,6 +181,23 @@ describe('dashboard foundation', () => {
expect(sourceImage).toHaveClass('is-blurred');
await user.click(within(preparation).getByRole('button', { name: 'Reveal source image: [25F] A potential post' }));
expect(sourceImage).not.toHaveClass('is-blurred');
const candidateList = screen.getByRole('list', { name: 'Active outreach candidates' });
await user.click(within(candidateList).getByRole('button', { name: 'Edit message' }));
const editDialog = screen.getByRole('dialog', { name: 'Edit message for u/alice' });
const editor = within(editDialog).getByRole('textbox', { name: 'Message' });
await user.clear(editor);
await user.type(editor, 'A carefully revised opener');
await user.click(within(editDialog).getByRole('button', { name: 'Save message' }));
await waitFor(() => expect(editedMessage).toBe('A carefully revised opener'));
expect(await screen.findByText('Message for u/alice updated.')).toBeInTheDocument();
await user.click(within(candidateList).getByRole('button', { name: 'Remove' }));
const deleteDialog = screen.getByRole('dialog', { name: 'Remove u/alice from the funnel?' });
expect(deleteDialog).toHaveTextContent('retained in the audit history');
await user.click(within(deleteDialog).getByRole('button', { name: 'Remove candidate' }));
await waitFor(() => expect(removedCandidate).toBe('candidate-1'));
expect(await screen.findByText('Removed u/alice from the funnel.')).toBeInTheDocument();
});
it('requires confirmation before clearing unsent outreach and preserves in-flight sends', async () => {
+63 -6
View File
@@ -3,7 +3,9 @@ import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
OutreachClearResponseSchema,
OutreachCandidateDeleteResponseSchema,
OutreachFunnelResponseSchema,
OutreachMessageUpdateResponseSchema,
} from '../../packages/api-contracts/src/index.js';
import type { WebContext } from '../../src/web/context.js';
import { createOutreachRouter } from '../../src/web/routes/outreach.js';
@@ -48,13 +50,14 @@ describe('outreach route', () => {
physiqueScore: 6,
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
sourceImageUrl: 'https://i.redd.it/post-1.jpg',
strategyId: 'direct-observation',
experimentId: 'strategies:direct-observation:abc123',
promptVersion: 'abc123',
selectionReason: 'exploit',
modelId: 'example/image-model',
strategyId: null,
experimentId: null,
promptVersion: null,
selectionReason: null,
modelId: 'operator-edited',
toneId: 'direct',
scripted: false,
operatorEdited: true,
}, priority: 2, status: 'sending',
discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000,
leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a',
@@ -89,7 +92,8 @@ describe('outreach route', () => {
physiqueScore: 6,
physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 },
sourceImageUrl: 'https://i.redd.it/post-1.jpg',
strategyId: 'direct-observation',
strategyId: null,
operatorEdited: true,
},
}],
});
@@ -136,4 +140,57 @@ describe('outreach route', () => {
expect.objectContaining({ total: 7, retainedSending: 1 }),
);
});
it('edits a prepared message and removes an individual unsent candidate', async () => {
const updatePreparedMessage = vi.fn(() => 'updated' as const);
const cancelUnsent = vi.fn(() => 'cancelled' as const);
const activity = { push: vi.fn() };
const broadcaster = { push: vi.fn() };
const context = {
services: { candidateQueue: { updatePreparedMessage, cancelUnsent } }, activity, broadcaster,
} as unknown as WebContext;
const app = express().use(express.json()).use('/api', createOutreachRouter(context));
const editResponse = await request(app, '/api/outreach/post%2Fone/message', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: ' A revised opener ' }),
});
expect(OutreachMessageUpdateResponseSchema.parse(await editResponse.json())).toEqual({
success: true, postId: 'post/one', message: 'A revised opener',
});
expect(updatePreparedMessage).toHaveBeenCalledWith('post/one', 'A revised opener');
const deleteResponse = await request(app, '/api/outreach/post%2Fone', { method: 'DELETE' });
expect(OutreachCandidateDeleteResponseSchema.parse(await deleteResponse.json())).toEqual({
success: true, postId: 'post/one',
});
expect(cancelUnsent).toHaveBeenCalledWith('post/one');
expect(activity.push).toHaveBeenCalledTimes(2);
});
it('rejects unsafe edits and stale mutations that reached the sending boundary', async () => {
const updatePreparedMessage = vi.fn(() => 'not-editable' as const);
const cancelUnsent = vi.fn(() => 'not-cancellable' as const);
const context = {
services: { candidateQueue: { updatePreparedMessage, cancelUnsent } },
activity: { push: vi.fn() }, broadcaster: { push: vi.fn() },
} as unknown as WebContext;
const app = express().use(express.json()).use('/api', createOutreachRouter(context));
const unsafe = await request(app, '/api/outreach/p1/message', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'We should meet tonight' }),
});
expect(unsafe.status).toBe(400);
expect(updatePreparedMessage).not.toHaveBeenCalled();
const staleEdit = await request(app, '/api/outreach/p1/message', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'A safe revised message' }),
});
expect(staleEdit.status).toBe(409);
const staleDelete = await request(app, '/api/outreach/p1', { method: 'DELETE' });
expect(staleDelete.status).toBe(409);
});
});