Add active outreach funnel dashboard

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

No files matched your search

@@ -243,6 +243,14 @@ export class CandidateQueueRepository {
.get() as { count: number }).count;
}
getOldestActiveAt(): number | null {
const row = this.db.prepare(`
SELECT MIN(discovered_at) AS at FROM candidate_queue
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
`).get(...ACTIVE_STATUSES) as { at: number | null };
return row.at;
}
getStats(): CandidateQueueStats {
const statuses: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable', 'sent', 'rejected'];
const byStatus = Object.fromEntries(statuses.map((status) => [status, 0])) as Record<CandidateStatus, number>;
@@ -265,6 +273,29 @@ export class CandidateQueueRepository {
};
}
listActive(limit = 500): CandidateQueueRecord[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
throw new Error('Candidate queue limit must be an integer between 1 and 500');
}
const rows = this.db.prepare(`
SELECT * FROM candidate_queue
WHERE status IN (${ACTIVE_STATUSES.map(() => '?').join(', ')})
ORDER BY
CASE status
WHEN 'sending' THEN 1
WHEN 'screening' THEN 2
WHEN 'ready' THEN 3
WHEN 'retryable' THEN 4
ELSE 5
END,
priority DESC,
post_created_at DESC,
discovered_at ASC
LIMIT ?
`).all(...ACTIVE_STATUSES, limit) as Array<Record<string, unknown>>;
return rows.map((row) => this.mapRow(row));
}
private claim(
from: 'discovered' | 'ready',
to: 'screening' | 'sending',
@@ -298,6 +329,10 @@ export class CandidateQueueRepository {
const row = this.db.prepare('SELECT * FROM candidate_queue WHERE post_id = ?')
.get(postId) as Record<string, unknown> | undefined;
if (!row) return null;
return this.mapRow(row);
}
private mapRow(row: Record<string, unknown>): CandidateQueueRecord {
return {
postId: row.post_id as string,
subreddit: row.subreddit as string,
+1
View File
@@ -97,6 +97,7 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
operation('getAnalyticsTrend', 'GET', '/api/analytics/trend', 'Get daily performance trend', ['Analytics']),
operation('getAiUsageAnalytics', 'GET', '/api/analytics/ai-usage', 'Get OpenRouter cost and model reliability', ['Analytics']),
operation('getAttention', 'GET', '/api/attention', 'Get operator attention items', ['Operations']),
operation('getOutreachFunnel', 'GET', '/api/outreach', 'Get active outreach candidates and funnel stages', ['Operations']),
operation('getConfig', 'GET', '/api/config', 'Get non-secret application configuration', ['Configuration']),
operation('updateConfig', 'PUT', '/api/config', 'Update application configuration', ['Configuration']),
operation('getLoginStatus', 'GET', '/api/login/status', 'Get Reddit session state', ['Accounts']),
+91
View File
@@ -0,0 +1,91 @@
import { Router } from 'express';
import type { CandidateStatus } from '../../database/candidate-queue-repository.js';
import type { WebContext } from '../context.js';
const ACTIVE_STATUSES = ['discovered', 'screening', 'ready', 'sending', 'retryable'] as const;
type ActiveCandidateStatus = Exclude<CandidateStatus, 'sent' | 'rejected'>;
interface OutreachCandidateView {
postId: string;
subreddit: string;
author: string;
title: string;
postUrl: string;
sourceType: 'image' | 'chat';
status: ActiveCandidateStatus;
priority: number;
postCreatedAt: number;
discoveredAt: number;
updatedAt: number;
availableAt: number;
attemptCount: number;
hasPreparedMessage: boolean;
note: string | null;
owner: { accountId: string; redditUsername: string } | null;
}
function redditPostUrl(permalink: string): string {
try {
return new URL(permalink, 'https://www.reddit.com').toString();
} catch {
return 'https://www.reddit.com';
}
}
export function createOutreachRouter(ctx: WebContext): Router {
const router = Router();
router.get('/outreach', (_req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) {
res.status(503).json({ error: 'Outreach queue not initialized' });
return;
}
const stats = queue.getStats();
const records = queue.listActive();
const accountById = new Map((ctx.services.redditAccounts?.list() ?? [])
.map((account) => [account.accountId, account.redditUsername] as const));
const candidates: OutreachCandidateView[] = records.map((candidate) => ({
postId: candidate.postId,
subreddit: candidate.subreddit,
author: candidate.author,
title: candidate.post.title,
postUrl: redditPostUrl(candidate.post.permalink),
sourceType: candidate.sourceType,
status: candidate.status as ActiveCandidateStatus,
priority: candidate.priority,
postCreatedAt: candidate.post.created_utc * 1_000,
discoveredAt: candidate.discoveredAt,
updatedAt: candidate.updatedAt,
availableAt: candidate.availableAt,
attemptCount: candidate.attemptCount,
hasPreparedMessage: candidate.prepared !== null,
note: candidate.lastError,
owner: candidate.assignedAccountId
? {
accountId: candidate.assignedAccountId,
redditUsername: accountById.get(candidate.assignedAccountId) ?? candidate.assignedAccountId,
}
: null,
}));
const stages = {
discovered: stats.byStatus.discovered,
screening: stats.byStatus.screening,
ready: stats.byStatus.ready,
sending: stats.byStatus.sending,
retryable: stats.byStatus.retryable,
};
const response = {
generatedAt: Date.now(),
activeCount: ACTIVE_STATUSES.reduce((total, status) => total + stats.byStatus[status], 0),
stages,
outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected },
oldestActiveAt: queue.getOldestActiveAt(),
candidates,
};
res.json(response);
});
return router;
}
+2
View File
@@ -42,6 +42,7 @@ import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
import { resolveDashboardAssets } from './dashboard-assets.js';
import { createAgentRouter, createHealthRouter } from './routes/agent.js';
import { createAttentionRouter } from './routes/attention.js';
import { createOutreachRouter } from './routes/outreach.js';
import type { AiUsageRepository } from '../database/ai-usage-repository.js';
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
@@ -157,6 +158,7 @@ export class WebServer {
this.app.use('/api', createAgentRouter(ctx));
this.app.use('/api', createStatsRouter(ctx));
this.app.use('/api', createAttentionRouter(ctx));
this.app.use('/api', createOutreachRouter(ctx));
this.app.use('/api', createChatsRouter(ctx));
this.app.use('/api', createConversationsRouter(ctx));
this.app.use('/api', createMediaRouter(ctx));