diff --git a/apps/dashboard/src/app/navigation.ts b/apps/dashboard/src/app/navigation.ts
index f3cbd02..9bae497 100644
--- a/apps/dashboard/src/app/navigation.ts
+++ b/apps/dashboard/src/app/navigation.ts
@@ -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' },
diff --git a/apps/dashboard/src/app/router.tsx b/apps/dashboard/src/app/router.tsx
index 4bb273b..1541096 100644
--- a/apps/dashboard/src/app/router.tsx
+++ b/apps/dashboard/src/app/router.tsx
@@ -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: },
{ path: 'attention', element: },
+ { path: 'outreach', element: },
{ path: 'conversations', element: },
{ path: 'conversations/:conversationId', element: },
{ path: 'media', element: },
diff --git a/apps/dashboard/src/features/outreach/outreach-api.ts b/apps/dashboard/src/features/outreach/outreach-api.ts
new file mode 100644
index 0000000..69b1f46
--- /dev/null
+++ b/apps/dashboard/src/features/outreach/outreach-api.ts
@@ -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();
diff --git a/apps/dashboard/src/features/outreach/outreach-page.tsx b/apps/dashboard/src/features/outreach/outreach-page.tsx
new file mode 100644
index 0000000..196a2c8
--- /dev/null
+++ b/apps/dashboard/src/features/outreach/outreach-page.tsx
@@ -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 (
+
+
+
+
+
+ {statusLabel(candidate.status)}
+ r/{candidate.subreddit}
+ {candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}
+
+
+
+ {candidate.title}
+
+
+ {candidate.hasPreparedMessage ? 'Message prepared' : 'Not prepared yet'}
+ {candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}
+ {waitingUntil ? Retry {formatWaitTime(candidate.availableAt, now)} : null}
+
+ {candidate.note ? Latest note{candidate.note}
: null}
+ {candidate.owner ? : null}
+
+
+ );
+}
+
+export function OutreachPage() {
+ const query = useQuery({
+ queryKey: ['outreach', 'funnel'],
+ queryFn: outreachApi.getFunnel,
+ refetchInterval: 10_000,
+ });
+ if (query.isPending) return Loading outreach funnel…
;
+ if (query.isError) return Outreach funnel unavailable{query.error.message}
;
+
+ const { data } = query;
+ const now = data.generatedAt;
+ return (
+
+
+
+
+
+
Current burn-down
{data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}
+
{data.outcomes.sent} sent{data.outcomes.rejected} screened out
+
+
+ {STAGES.map((stage) => (
+ -
+
+
{stage.label}{stage.description}
+ {data.stages[stage.status]}
+
+ ))}
+
+ Retrying posts loop back to Ready when their cooldown expires. Sent and screened-out totals cover the lifetime of this queue.
+
+
+
+ Potential posts
Active queue
{data.candidates.length < data.activeCount ? `Showing ${data.candidates.length} of ${data.activeCount} · ` : ''}Updated {formatShortRelativeTime(data.generatedAt, Date.now())}
+ {data.candidates.length === 0 ? (
+ The active queue is clear.New potential posts will appear here as discovery finds them.
+ ) : (
+
+ {data.candidates.map((candidate) => )}
+
+ )}
+
+
+ );
+}
diff --git a/apps/dashboard/src/features/outreach/outreach.css b/apps/dashboard/src/features/outreach/outreach.css
new file mode 100644
index 0000000..6d33b03
--- /dev/null
+++ b/apps/dashboard/src/features/outreach/outreach.css
@@ -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; } }
+}
diff --git a/docs/api.md b/docs/api.md
index 5056192..535ba21 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -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 |
diff --git a/packages/api-contracts/src/index.ts b/packages/api-contracts/src/index.ts
index e10ed60..a3672a4 100644
--- a/packages/api-contracts/src/index.ts
+++ b/packages/api-contracts/src/index.ts
@@ -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';
diff --git a/packages/api-contracts/src/outreach.ts b/packages/api-contracts/src/outreach.ts
new file mode 100644
index 0000000..5aa22fc
--- /dev/null
+++ b/packages/api-contracts/src/outreach.ts
@@ -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;
+export type OutreachCandidate = z.infer;
+export type OutreachFunnelResponse = z.infer;
diff --git a/src/database/candidate-queue-repository.ts b/src/database/candidate-queue-repository.ts
index b2363d8..7b523aa 100644
--- a/src/database/candidate-queue-repository.ts
+++ b/src/database/candidate-queue-repository.ts
@@ -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;
@@ -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>;
+ 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 | undefined;
if (!row) return null;
+ return this.mapRow(row);
+ }
+
+ private mapRow(row: Record): CandidateQueueRecord {
return {
postId: row.post_id as string,
subreddit: row.subreddit as string,
diff --git a/src/web/api-catalog.ts b/src/web/api-catalog.ts
index 52912d2..c401e0e 100644
--- a/src/web/api-catalog.ts
+++ b/src/web/api-catalog.ts
@@ -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']),
diff --git a/src/web/routes/outreach.ts b/src/web/routes/outreach.ts
new file mode 100644
index 0000000..1711b25
--- /dev/null
+++ b/src/web/routes/outreach.ts
@@ -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;
+
+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;
+}
diff --git a/src/web/server.ts b/src/web/server.ts
index e44ee5c..bfb526e 100644
--- a/src/web/server.ts
+++ b/src/web/server.ts
@@ -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));
diff --git a/tests/candidate-queue.test.ts b/tests/candidate-queue.test.ts
index 622fb9b..726ce2a 100644
--- a/tests/candidate-queue.test.ts
+++ b/tests/candidate-queue.test.ts
@@ -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', () => {
diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx
index 2dee6d0..3176a92 100644
--- a/tests/dashboard/app.test.tsx
+++ b/tests/dashboard/app.test.tsx
@@ -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(
diff --git a/tests/web/outreach-route.test.ts b/tests/web/outreach-route.test.ts
new file mode 100644
index 0000000..a03bbc9
--- /dev/null
+++ b/tests/web/outreach-route.test.ts
@@ -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> = [];
+
+async function request(app: express.Express, path: string): Promise {
+ const server = app.listen(0);
+ servers.push(server);
+ await new Promise((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((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' });
+ });
+});