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
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user