From f55ae7ddf87b616bc4ec5494f4ec6cbe54983fe5 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 9 Aug 2026 15:54:05 +0100 Subject: [PATCH] Add active outreach funnel dashboard --- apps/dashboard/src/app/navigation.ts | 1 + apps/dashboard/src/app/router.tsx | 2 + .../src/features/outreach/outreach-api.ts | 22 ++++ .../src/features/outreach/outreach-page.tsx | 110 ++++++++++++++++++ .../src/features/outreach/outreach.css | 58 +++++++++ docs/api.md | 1 + packages/api-contracts/src/index.ts | 1 + packages/api-contracts/src/outreach.ts | 53 +++++++++ src/database/candidate-queue-repository.ts | 35 ++++++ src/web/api-catalog.ts | 1 + src/web/routes/outreach.ts | 91 +++++++++++++++ src/web/server.ts | 2 + tests/candidate-queue.test.ts | 22 ++++ tests/dashboard/app.test.tsx | 39 ++++++- tests/web/outreach-route.test.ts | 82 +++++++++++++ 15 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/features/outreach/outreach-api.ts create mode 100644 apps/dashboard/src/features/outreach/outreach-page.tsx create mode 100644 apps/dashboard/src/features/outreach/outreach.css create mode 100644 packages/api-contracts/src/outreach.ts create mode 100644 src/web/routes/outreach.ts create mode 100644 tests/web/outreach-route.test.ts 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 ( +
    +
    +
    Live candidate queue

    Outreach funnel

    Follow every potential post from discovery to delivery, and see exactly where the remaining queue is waiting.

    + +
    + +
    +
    +
    Current burn-down

    {data.activeCount} active {data.activeCount === 1 ? 'post' : 'posts'}

    +
    {data.outcomes.sent} sent{data.outcomes.rejected} screened out
    +
    +
      + {STAGES.map((stage) => ( +
    1. +
    2. + ))} +
    +

    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' }); + }); +});