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
@@ -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' },
|
||||
|
||||
@@ -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; } }
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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