Add outreach candidate editing controls
This commit is contained in:
1 parent
c75e71f26e
commit
79c508be2b
19 files changed
+434
-12
No files matched your search
@@ -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)!;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user