Add active outreach funnel dashboard
This commit is contained in:
1 parent
deaf86c364
commit
f55ae7ddf8
15 files changed
+519
-1
No files matched your search
@@ -126,6 +126,28 @@ describe('CandidateQueueRepository', () => {
|
||||
prepared: expect.objectContaining({ generatedMessage: 'prepared message' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('lists only active candidates in actionable stage order', () => {
|
||||
queue.enqueue([
|
||||
post('waiting', 'alice'),
|
||||
post('screening', 'bob'),
|
||||
post('ready', 'carol'),
|
||||
post('sending', 'dana'),
|
||||
post('rejected', 'erin'),
|
||||
], 'image', 1, 1_000);
|
||||
queue.claimForScreening('worker-1', 1_000, 1_010);
|
||||
const ready = queue.claimForScreening('worker-2', 1_000, 1_020)!;
|
||||
queue.markReady(ready.postId, 'worker-2', prepared(ready.post), 1_021);
|
||||
const sending = queue.claimReady('account-a', 'worker-3', 1_000, 1_030)!;
|
||||
const rejected = queue.claimForScreening('worker-4', 1_000, 1_040)!;
|
||||
queue.reject(rejected.postId, 'worker-4', 'Seller signals detected', 1_041);
|
||||
|
||||
expect(queue.listActive().map((candidate) => candidate.status)).toEqual([
|
||||
'sending', 'screening', 'discovered', 'discovered',
|
||||
]);
|
||||
expect(queue.listActive().map((candidate) => candidate.postId)).not.toContain(rejected.postId);
|
||||
expect(queue.getOldestActiveAt()).toBe(1_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RuntimeStateRepository', () => {
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('dashboard foundation', () => {
|
||||
const { router } = renderDashboard('/');
|
||||
|
||||
const navigation = screen.getByRole('navigation', { name: 'Primary navigation' });
|
||||
for (const label of ['Overview', 'Needs attention', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) {
|
||||
for (const label of ['Overview', 'Needs attention', 'Outreach funnel', 'Conversations', 'Media', 'Performance', 'Operations', 'Settings']) {
|
||||
expect(within(navigation).getByRole('link', { name: label })).toBeInTheDocument();
|
||||
}
|
||||
|
||||
@@ -109,6 +109,43 @@ describe('dashboard foundation', () => {
|
||||
expect(screen.getByRole('link', { name: 'Open conversation' })).toHaveAttribute('href', '/conversations/%40one%3Areddit.com~!room%3Areddit.com');
|
||||
});
|
||||
|
||||
it('shows the live outreach stages and the potential posts being burned down', async () => {
|
||||
installSuccessfulApi();
|
||||
server.use(
|
||||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
|
||||
http.get('/api/chats/sync', () => HttpResponse.json({
|
||||
syncing: false, syncingKind: null, lastFullSyncAt: null, lastDeltaSyncAt: null,
|
||||
hasEverFullSynced: false, cachedChats: 0, progress: null,
|
||||
})),
|
||||
http.get('/api/outreach', () => HttpResponse.json({
|
||||
generatedAt: Date.parse('2026-08-09T08:00:00Z'),
|
||||
activeCount: 3,
|
||||
stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 },
|
||||
outcomes: { sent: 12, rejected: 7 },
|
||||
oldestActiveAt: Date.parse('2026-08-09T07:00:00Z'),
|
||||
candidates: [{
|
||||
postId: 'candidate-1', subreddit: 'example', author: 'alice', title: '[25F] A potential post',
|
||||
postUrl: 'https://www.reddit.com/r/example/comments/candidate-1/', sourceType: 'image',
|
||||
status: 'ready', priority: 2, postCreatedAt: Date.parse('2026-08-09T06:50:00Z'),
|
||||
discoveredAt: Date.parse('2026-08-09T07:00:00Z'), updatedAt: Date.parse('2026-08-09T07:30:00Z'),
|
||||
availableAt: Date.parse('2026-08-09T07:30:00Z'), attemptCount: 1, hasPreparedMessage: true,
|
||||
note: null, owner: null,
|
||||
}],
|
||||
})),
|
||||
);
|
||||
|
||||
renderDashboard('/outreach');
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post');
|
||||
expect(screen.getByRole('link', { name: '[25F] A potential post' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://www.reddit.com/r/example/comments/candidate-1/',
|
||||
);
|
||||
expect(within(screen.getByRole('list', { name: 'Active outreach candidates' })).getByText('Message prepared')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hydrates media filters from the routed URL', async () => {
|
||||
installSuccessfulApi();
|
||||
server.use(
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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 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> {
|
||||
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}`);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
|
||||
});
|
||||
|
||||
describe('outreach route', () => {
|
||||
it('publishes active candidates and queue outcomes without prepared message content', async () => {
|
||||
const candidateQueue = {
|
||||
getStats: vi.fn(() => ({
|
||||
total: 9,
|
||||
byStatus: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1, sent: 2, rejected: 1 },
|
||||
ready: 1,
|
||||
oldestReadyAt: 2_000,
|
||||
rejectionReasons: [],
|
||||
})),
|
||||
getOldestActiveAt: vi.fn(() => 1_000),
|
||||
listActive: vi.fn(() => [{
|
||||
postId: 'post-1', subreddit: 'example', author: 'alice', sourceType: 'image',
|
||||
post: {
|
||||
id: 'post-1', subreddit: 'example', author: 'alice', author_fullname: 't2_alice',
|
||||
title: '[25F] Hello', url: 'https://i.redd.it/post-1.jpg', permalink: '/r/example/comments/post-1/hello/',
|
||||
thumbnail: '', is_self: false, created_utc: 100, over_18: true, spoiler: false,
|
||||
},
|
||||
prepared: { generatedMessage: 'private prepared text' }, priority: 2, status: 'sending',
|
||||
discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000,
|
||||
leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a',
|
||||
attemptCount: 2, lastError: null,
|
||||
}]),
|
||||
};
|
||||
const context = {
|
||||
services: {
|
||||
candidateQueue,
|
||||
redditAccounts: { list: () => [{ accountId: 'account-a', redditUsername: 'operator' }] },
|
||||
},
|
||||
} as unknown as WebContext;
|
||||
const app = express().use('/api', createOutreachRouter(context));
|
||||
|
||||
const response = await request(app, '/api/outreach');
|
||||
const body: unknown = await response.json();
|
||||
const parsed = OutreachFunnelResponseSchema.parse(body);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(parsed).toMatchObject({
|
||||
activeCount: 6,
|
||||
stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 },
|
||||
outcomes: { sent: 2, rejected: 1 },
|
||||
candidates: [{
|
||||
postId: 'post-1', status: 'sending', hasPreparedMessage: true,
|
||||
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',
|
||||
owner: { accountId: 'account-a', redditUsername: 'operator' },
|
||||
}],
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain('private prepared text');
|
||||
expect(JSON.stringify(body)).not.toContain('worker-secret');
|
||||
});
|
||||
|
||||
it('reports an unavailable queue instead of an empty funnel', async () => {
|
||||
const context = { services: {} } as unknown as WebContext;
|
||||
const app = express().use('/api', createOutreachRouter(context));
|
||||
|
||||
const response = await request(app, '/api/outreach');
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toEqual({ error: 'Outreach queue not initialized' });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user