Add active outreach funnel dashboard

This commit is contained in:
jjbrunton committed 2026-08-09 15:54:05 +01:00
1 parent deaf86c364
commit f55ae7ddf8
15 files changed
+519 -1

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ export interface NavigationItem {
export const navigationItems: readonly NavigationItem[] = [
{ label: 'Overview', path: '/', description: 'System health and current activity' },
{ label: 'Needs attention', path: '/attention', description: 'Handoffs, failures, and account risks' },
{ label: 'Outreach funnel', path: '/outreach', description: 'Active posts moving toward delivery' },
{ label: 'Conversations', path: '/conversations', description: 'Inbox and message history' },
{ label: 'Media', path: '/media', description: 'Private media and sync controls' },
{ label: 'Performance', path: '/performance', description: 'Response and strategy trends' },
+2
View File
@@ -8,6 +8,7 @@ import { PerformancePage } from '../features/performance/performance-page.js';
import { SettingsRoute } from '../features/settings/settings-route.js';
import { RouteErrorPage } from './route-error-page.js';
import { AttentionPage } from '../features/attention/attention-page.js';
import { OutreachPage } from '../features/outreach/outreach-page.js';
export function createDashboardRoutes(): RouteObject[] {
return [
@@ -18,6 +19,7 @@ export function createDashboardRoutes(): RouteObject[] {
children: [
{ index: true, element: <OverviewPage /> },
{ path: 'attention', element: <AttentionPage /> },
{ path: 'outreach', element: <OutreachPage /> },
{ path: 'conversations', element: <ConversationRoute /> },
{ path: 'conversations/:conversationId', element: <ConversationRoute /> },
{ path: 'media', element: <MediaRoute /> },
@@ -0,0 +1,22 @@
import { OutreachFunnelResponseSchema } from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js';
export function createOutreachApi(fetchImplementation?: typeof fetch) {
const request = createDashboardRequest(fetchImplementation);
return {
async getFunnel() {
const response = await request('/api/outreach');
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
: `Outreach funnel unavailable (${response.status})`;
throw new Error(message);
}
return OutreachFunnelResponseSchema.parse(payload);
},
};
}
export const outreachApi = createOutreachApi();
@@ -0,0 +1,110 @@
import { useQuery } from '@tanstack/react-query';
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
import { outreachApi } from './outreach-api.js';
import './outreach.css';
const STAGES: ReadonlyArray<{
status: OutreachCandidateStatus;
label: string;
description: string;
}> = [
{ status: 'discovered', label: 'Waiting', description: 'Found and queued' },
{ status: 'screening', label: 'Screening', description: 'Checking eligibility' },
{ status: 'ready', label: 'Ready', description: 'Message prepared' },
{ status: 'sending', label: 'Sending', description: 'Assigned to account' },
{ status: 'retryable', label: 'Retrying', description: 'Cooling down' },
];
function statusLabel(status: OutreachCandidateStatus): string {
return STAGES.find((stage) => stage.status === status)?.label ?? status;
}
function formatWaitTime(timestamp: number, now: number): string {
const remaining = Math.max(0, timestamp - now);
if (remaining < 60_000) return 'in under a minute';
if (remaining < 3_600_000) return `in ${Math.ceil(remaining / 60_000)}m`;
if (remaining < 86_400_000) return `in ${Math.ceil(remaining / 3_600_000)}h`;
return `in ${Math.ceil(remaining / 86_400_000)}d`;
}
function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) {
const waitingUntil = candidate.availableAt > now;
return (
<li className={`outreach-candidate is-${candidate.status}`}>
<div className="candidate-status-rail" aria-hidden="true"><span /></div>
<article>
<div className="candidate-heading">
<div className="candidate-labels">
<span className={`candidate-status status-${candidate.status}`}>{statusLabel(candidate.status)}</span>
<span>r/{candidate.subreddit}</span>
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
</div>
<time dateTime={new Date(candidate.discoveredAt).toISOString()} title={formatExactTime(candidate.discoveredAt)}>
queued {formatShortRelativeTime(candidate.discoveredAt, now)}
</time>
</div>
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">{candidate.title}</a>
<div className="candidate-meta">
<RedditUsernameLink username={candidate.author} />
<span>{candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}</span>
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
</div>
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
</article>
</li>
);
}
export function OutreachPage() {
const query = useQuery({
queryKey: ['outreach', 'funnel'],
queryFn: outreachApi.getFunnel,
refetchInterval: 10_000,
});
if (query.isPending) return <div className="page-state" role="status">Loading outreach funnel…</div>;
if (query.isError) return <div className="page-state error-state" role="alert"><strong>Outreach funnel unavailable</strong><span>{query.error.message}</span><button className="button secondary" onClick={() => void query.refetch()} type="button">Retry</button></div>;
const { data } = query;
const now = data.generatedAt;
return (
<div className="page outreach-page">
<header className="page-header">
<div><span className="eyebrow">Live candidate queue</span><h1>Outreach funnel</h1><p>Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.</p></div>
<button className="button secondary" disabled={query.isFetching} onClick={() => void query.refetch()} type="button">{query.isFetching ? 'Refreshing…' : 'Refresh'}</button>
</header>
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
<div className="runway-heading">
<div><span className="eyebrow">Current burn-down</span><h2 id="active-funnel-heading">{data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}</h2></div>
<div className="runway-outcomes"><span><strong>{data.outcomes.sent}</strong> sent</span><span><strong>{data.outcomes.rejected}</strong> screened out</span></div>
</div>
<ol className="outreach-stages">
{STAGES.map((stage) => (
<li className={`stage-${stage.status}`} key={stage.status}>
<span className="stage-marker" aria-hidden="true" />
<div><span>{stage.label}</span><small>{stage.description}</small></div>
<strong>{data.stages[stage.status]}</strong>
</li>
))}
</ol>
<p className="runway-caption">Retrying posts loop back to Ready when their cooldown expires. Sent and screened-out totals cover the lifetime of this queue.</p>
</section>
<section aria-labelledby="active-candidates-heading" className="candidate-section">
<div className="panel-heading"><div><span className="eyebrow">Potential posts</span><h2 id="active-candidates-heading">Active queue</h2></div><span className="candidate-updated">{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}Updated {formatShortRelativeTime(data.generatedAt, Date.now())}</span></div>
{data.candidates.length === 0 ? (
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
) : (
<ol aria-label="Active outreach candidates" className="outreach-candidates">
{data.candidates.map((candidate) => <CandidateCard candidate={candidate} key={candidate.postId} now={now} />)}
</ol>
)}
</section>
</div>
);
}
@@ -0,0 +1,58 @@
.outreach-page { display: grid; gap: 1rem; }
.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; }
.runway-heading h2 { font-size: clamp(1.55rem, 3vw, 2.25rem); letter-spacing: -.045em; }
.runway-outcomes { display: flex; gap: .5rem; }
.runway-outcomes span { display: grid; min-width: 5.7rem; padding: .55rem .7rem; color: var(--muted); background: rgba(9, 17, 14, .7); border: 1px solid var(--border); border-radius: .65rem; font-size: .72rem; }
.runway-outcomes strong { color: #e9f1ec; font-size: 1.15rem; }
.outreach-stages { position: relative; display: grid; gap: .55rem; padding: 0; margin: 0; list-style: none; }
.outreach-stages li { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: .75rem; padding: .75rem; background: rgba(23, 35, 30, .86); border: 1px solid var(--border); border-radius: .75rem; }
.outreach-stages li > div { display: grid; gap: .05rem; }
.outreach-stages li span { font-weight: 750; }
.outreach-stages li small { color: var(--muted); }
.outreach-stages li > strong { font-size: 1.4rem; }
.stage-marker { width: .65rem; height: .65rem; border-radius: 50%; background: #668077; box-shadow: 0 0 0 .25rem rgba(102, 128, 119, .12); }
.stage-screening .stage-marker { background: #86b7d7; }
.stage-ready .stage-marker { background: var(--accent); }
.stage-sending .stage-marker { background: #c1ef91; box-shadow: 0 0 0 .25rem rgba(193, 239, 145, .12); }
.stage-retryable .stage-marker { background: var(--warning); }
.runway-caption { position: relative; margin: .9rem 0 0; color: var(--muted); font-size: .78rem; }
.candidate-section { margin-top: .5rem; }
.candidate-updated { color: var(--muted); font-size: .8rem; }
.outreach-candidates { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
.outreach-candidate { display: grid; grid-template-columns: .32rem minmax(0, 1fr); overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; }
.candidate-status-rail { background: #668077; }
.candidate-status-rail span { display: block; width: 100%; height: 3.1rem; background: #8ca49c; }
.outreach-candidate.is-screening .candidate-status-rail span { background: #86b7d7; }
.outreach-candidate.is-ready .candidate-status-rail span { background: var(--accent); }
.outreach-candidate.is-sending .candidate-status-rail span { background: #c1ef91; }
.outreach-candidate.is-retryable .candidate-status-rail span { background: var(--warning); }
.outreach-candidate article { display: grid; gap: .7rem; min-width: 0; padding: .9rem 1rem; }
.candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.candidate-labels { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; color: var(--muted); font-size: .72rem; }
.candidate-status { padding: .25rem .45rem; color: #dce8e2; background: #2b3832; border-radius: .35rem; font-weight: 800; }
.status-screening { color: #d6edfc; background: #243a48; }
.status-ready, .status-sending { color: #bff4d2; background: #163a26; }
.status-retryable { color: #ffe1a0; background: #42351b; }
.candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
.candidate-title { width: fit-content; max-width: 100%; color: #eef6f1; font-size: 1rem; font-weight: 720; line-height: 1.35; text-decoration-color: #426655; text-underline-offset: .22em; overflow-wrap: anywhere; }
.candidate-title:hover { text-decoration-color: var(--accent); }
.candidate-meta { display: flex; flex-wrap: wrap; gap: .4rem .8rem; color: var(--muted); font-size: .76rem; }
.candidate-note { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #d9c99e; background: #251f13; border-left: 2px solid var(--warning); border-radius: .35rem; font-size: .78rem; overflow-wrap: anywhere; }
.candidate-note strong { color: #ffe1a0; font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
.outreach-candidate .account-owner-badge { margin-top: -.1rem; }
@media (min-width: 64rem) {
.outreach-stages { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0; }
.outreach-stages::before { position: absolute; top: 50%; left: 8%; right: 8%; height: 1px; content: ""; background: #3f5a4e; }
.outreach-stages li { position: relative; grid-template-columns: auto minmax(0, 1fr); margin-right: .55rem; }
.outreach-stages li:last-child { margin-right: 0; }
.outreach-stages li > strong { grid-column: 2; }
}
@media (prefers-reduced-motion: no-preference) {
.outreach-candidate.is-screening .candidate-status-rail span,
.outreach-candidate.is-sending .candidate-status-rail span { animation: queue-pulse 2.4s ease-in-out infinite; }
@keyframes queue-pulse { 50% { opacity: .45; } }
}
+1
View File
@@ -89,6 +89,7 @@ an administrator credential.
| `GET` | `/api/analytics/trend` | Get daily performance trend | read | no |
| `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 |
| `GET` | `/api/config` | Get non-secret application configuration | read | no |
| `PUT` | `/api/config` | Update application configuration | admin | yes |
| `GET` | `/api/login/status` | Get Reddit session state | read | no |
+1
View File
@@ -6,5 +6,6 @@ export * from './config.js';
export * from './events.js';
export * from './media.js';
export * from './messages.js';
export * from './outreach.js';
export * from './session.js';
export * from './stats.js';
+53
View File
@@ -0,0 +1,53 @@
import { z } from 'zod';
import { RedditAccountOwnerSchema } from './session.js';
export const OutreachCandidateStatusSchema = z.enum([
'discovered',
'screening',
'ready',
'sending',
'retryable',
]);
export const OutreachStageCountsSchema = z.strictObject({
discovered: z.number().int().nonnegative(),
screening: z.number().int().nonnegative(),
ready: z.number().int().nonnegative(),
sending: z.number().int().nonnegative(),
retryable: z.number().int().nonnegative(),
});
export const OutreachCandidateSchema = z.strictObject({
postId: z.string(),
subreddit: z.string(),
author: z.string(),
title: z.string(),
postUrl: z.string(),
sourceType: z.enum(['image', 'chat']),
status: OutreachCandidateStatusSchema,
priority: z.number(),
postCreatedAt: z.number(),
discoveredAt: z.number(),
updatedAt: z.number(),
availableAt: z.number(),
attemptCount: z.number().int().nonnegative(),
hasPreparedMessage: z.boolean(),
note: z.string().nullable(),
owner: RedditAccountOwnerSchema.nullable(),
});
export const OutreachFunnelResponseSchema = z.strictObject({
generatedAt: z.number(),
activeCount: z.number().int().nonnegative(),
stages: OutreachStageCountsSchema,
outcomes: z.strictObject({
sent: z.number().int().nonnegative(),
rejected: z.number().int().nonnegative(),
}),
oldestActiveAt: z.number().nullable(),
candidates: z.array(OutreachCandidateSchema),
});
export type OutreachCandidateStatus = z.infer<typeof OutreachCandidateStatusSchema>;
export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>;
export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>;
@@ -243,6 +243,14 @@ export class CandidateQueueRepository {
.get() as { count: number }).count;
}
getOldestActiveAt(): number | null {
const row = this.db.prepare(`
SELECT MIN(discovered_at) AS at FROM candidate_queue
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
`).get(...ACTIVE_STATUSES) as { at: number | null };
return row.at;
}
getStats(): CandidateQueueStats {
const statuses: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable', 'sent', 'rejected'];
const byStatus = Object.fromEntries(statuses.map((status) => [status, 0])) as Record<CandidateStatus, number>;
@@ -265,6 +273,29 @@ export class CandidateQueueRepository {
};
}
listActive(limit = 500): CandidateQueueRecord[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
throw new Error('Candidate queue limit must be an integer between 1 and 500');
}
const rows = this.db.prepare(`
SELECT * FROM candidate_queue
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
ORDER BY
CASE status
WHEN 'sending' THEN 1
WHEN 'screening' THEN 2
WHEN 'ready' THEN 3
WHEN 'retryable' THEN 4
ELSE 5
END,
priority DESC,
post_created_at DESC,
discovered_at ASC
LIMIT ?
`).all(...ACTIVE_STATUSES, limit) as Array<Record<string, unknown>>;
return rows.map((row) => this.mapRow(row));
}
private claim(
from: 'discovered' | 'ready',
to: 'screening' | 'sending',
@@ -298,6 +329,10 @@ export class CandidateQueueRepository {
const row = this.db.prepare('SELECT * FROM candidate_queue WHERE post_id = ?')
.get(postId) as Record<string, unknown> | undefined;
if (!row) return null;
return this.mapRow(row);
}
private mapRow(row: Record<string, unknown>): CandidateQueueRecord {
return {
postId: row.post_id as string,
subreddit: row.subreddit as string,
+1
View File
@@ -97,6 +97,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
operation('getAnalyticsTrend', 'GET', '/api/analytics/trend', 'Get daily performance trend', ['Analytics']),
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('getConfig', 'GET', '/api/config', 'Get non-secret application configuration', ['Configuration']),
operation('updateConfig', 'PUT', '/api/config', 'Update application configuration', ['Configuration']),
operation('getLoginStatus', 'GET', '/api/login/status', 'Get Reddit session state', ['Accounts']),
+91
View File
@@ -0,0 +1,91 @@
import { Router } from 'express';
import type { CandidateStatus } from '../../database/candidate-queue-repository.js';
import type { WebContext } from '../context.js';
const ACTIVE_STATUSES = ['discovered', 'screening', 'ready', 'sending', 'retryable'] as const;
type ActiveCandidateStatus = Exclude<CandidateStatus, 'sent' | 'rejected'>;
interface OutreachCandidateView {
postId: string;
subreddit: string;
author: string;
title: string;
postUrl: string;
sourceType: 'image' | 'chat';
status: ActiveCandidateStatus;
priority: number;
postCreatedAt: number;
discoveredAt: number;
updatedAt: number;
availableAt: number;
attemptCount: number;
hasPreparedMessage: boolean;
note: string | null;
owner: { accountId: string; redditUsername: string } | null;
}
function redditPostUrl(permalink: string): string {
try {
return new URL(permalink, 'https://www.reddit.com').toString();
} catch {
return 'https://www.reddit.com';
}
}
export function createOutreachRouter(ctx: WebContext): Router {
const router = Router();
router.get('/outreach', (_req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) {
res.status(503).json({ error: 'Outreach queue not initialized' });
return;
}
const stats = queue.getStats();
const records = queue.listActive();
const accountById = new Map((ctx.services.redditAccounts?.list() ?? [])
.map((account) => [account.accountId, account.redditUsername] as const));
const candidates: OutreachCandidateView[] = records.map((candidate) => ({
postId: candidate.postId,
subreddit: candidate.subreddit,
author: candidate.author,
title: candidate.post.title,
postUrl: redditPostUrl(candidate.post.permalink),
sourceType: candidate.sourceType,
status: candidate.status as ActiveCandidateStatus,
priority: candidate.priority,
postCreatedAt: candidate.post.created_utc * 1_000,
discoveredAt: candidate.discoveredAt,
updatedAt: candidate.updatedAt,
availableAt: candidate.availableAt,
attemptCount: candidate.attemptCount,
hasPreparedMessage: candidate.prepared !== null,
note: candidate.lastError,
owner: candidate.assignedAccountId
? {
accountId: candidate.assignedAccountId,
redditUsername: accountById.get(candidate.assignedAccountId) ?? candidate.assignedAccountId,
}
: null,
}));
const stages = {
discovered: stats.byStatus.discovered,
screening: stats.byStatus.screening,
ready: stats.byStatus.ready,
sending: stats.byStatus.sending,
retryable: stats.byStatus.retryable,
};
const response = {
generatedAt: Date.now(),
activeCount: ACTIVE_STATUSES.reduce((total, status) => total + stats.byStatus[status], 0),
stages,
outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected },
oldestActiveAt: queue.getOldestActiveAt(),
candidates,
};
res.json(response);
});
return router;
}
+2
View File
@@ -42,6 +42,7 @@ import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
import { resolveDashboardAssets } from './dashboard-assets.js';
import { createAgentRouter, createHealthRouter } from './routes/agent.js';
import { createAttentionRouter } from './routes/attention.js';
import { createOutreachRouter } from './routes/outreach.js';
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
@@ -157,6 +158,7 @@ export class WebServer {
this.app.use('/api', createAgentRouter(ctx));
this.app.use('/api', createStatsRouter(ctx));
this.app.use('/api', createAttentionRouter(ctx));
this.app.use('/api', createOutreachRouter(ctx));
this.app.use('/api', createChatsRouter(ctx));
this.app.use('/api', createConversationsRouter(ctx));
this.app.use('/api', createMediaRouter(ctx));
+22
View File
@@ -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', () => {
+38 -1
View File
@@ -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(
+82
View File
@@ -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' });
});
});