import { Router } from 'express'; import { z } from 'zod'; import type { CandidateStatus } from '../../database/candidate-queue-repository.js'; import { isSafeOutboundMessage } from '../../services/outbound-message-safety.js'; import type { WebContext } from '../context.js'; const ACTIVE_STATUSES = ['discovered', 'screening', 'ready', 'sending', 'retryable'] as const; const MessageUpdateSchema = z.strictObject({ message: z.string().trim().min(1).max(2000) }); type ActiveCandidateStatus = Exclude; interface OutreachCandidateView { postId: string; subreddit: string; author: string; title: string; postUrl: string; targetComment: { body: string; permalink: string } | null; sourceType: 'image' | 'chat'; status: ActiveCandidateStatus; priority: number; postCreatedAt: number; discoveredAt: number; updatedAt: number; availableAt: number; attemptCount: number; hasPreparedMessage: boolean; physiqueScore: number | null; preparation: { message: string; imageAnalysis: string; detectedGender: 'male' | 'female' | 'other' | 'unknown'; attractivenessScore: number | null; physiqueScore: number | null; physiqueTargeting: { enabled: boolean; minScore: number; maxScore: number } | null; sourceImageUrl: string | null; strategyId: string | null; experimentId: string | null; promptVersion: string | null; selectionReason: 'explore' | 'exploit' | null; modelId: string; toneId: string; scripted: boolean; } | null; 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), targetComment: candidate.post.targetComment ? { body: candidate.post.targetComment.body, permalink: redditPostUrl(candidate.post.targetComment.permalink), } : null, 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, physiqueScore: candidate.prepared?.physiqueScore ?? null, preparation: candidate.prepared ? { message: candidate.prepared.generatedMessage, imageAnalysis: candidate.prepared.imageAnalysis, detectedGender: candidate.prepared.detectedGender, attractivenessScore: candidate.prepared.attractivenessScore ?? null, physiqueScore: candidate.prepared.physiqueScore ?? null, physiqueTargeting: candidate.sourceType === 'image' ? { ...ctx.runtimeConfig.reddit.physiqueTargeting } : null, sourceImageUrl: candidate.prepared.sourceImageUrl ?? null, strategyId: candidate.prepared.strategyId, experimentId: candidate.prepared.experimentId, promptVersion: candidate.prepared.promptVersion, selectionReason: candidate.prepared.selectionReason, modelId: candidate.prepared.modelId, toneId: candidate.prepared.toneId, scripted: candidate.prepared.scripted, operatorEdited: candidate.prepared.operatorEdited ?? false, } : 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 }, rejectionReasons: stats.rejectionReasons, oldestActiveAt: queue.getOldestActiveAt(), candidates, }; res.json(response); }); router.delete('/outreach', (_req, res) => { const queue = ctx.services.candidateQueue; if (!queue) { res.status(503).json({ error: 'Outreach queue not initialized' }); return; } const result = queue.clearUnsent(); const message = `Cleared ${result.total} unsent outreach candidate(s)` + (result.retainedSending > 0 ? `; retained ${result.retainedSending} in-flight send(s)` : ''); ctx.activity.push('info', message); ctx.broadcaster.push('warn', message, result); res.json({ success: true, cleared: { discovered: result.discovered, screening: result.screening, ready: result.ready, retryable: result.retryable, total: result.total, }, retainedSending: result.retainedSending, }); }); router.patch('/outreach/:postId/message', (req, res) => { const queue = ctx.services.candidateQueue; if (!queue) return res.status(503).json({ error: 'Outreach queue not initialized' }); const parsed = MessageUpdateSchema.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: 'Message must contain 1–2000 characters' }); if (!isSafeOutboundMessage(parsed.data.message)) { return res.status(400).json({ error: 'Message fails location or in-person meeting safeguards' }); } const result = queue.updatePreparedMessage(req.params.postId, parsed.data.message); if (result === 'not-found') return res.status(404).json({ error: 'Outreach candidate not found' }); if (result === 'not-editable') { return res.status(409).json({ error: 'Only ready or retrying messages can be edited' }); } const message = `Edited prepared outreach message for candidate ${req.params.postId}`; ctx.activity.push('info', message); ctx.broadcaster.push('info', message); return res.json({ success: true, postId: req.params.postId, message: parsed.data.message }); }); router.delete('/outreach/:postId', (req, res) => { const queue = ctx.services.candidateQueue; if (!queue) return res.status(503).json({ error: 'Outreach queue not initialized' }); const result = queue.cancelUnsent(req.params.postId); if (result === 'not-found') return res.status(404).json({ error: 'Outreach candidate not found' }); if (result === 'not-cancellable') { return res.status(409).json({ error: 'Candidate is already sending or has finished' }); } const message = `Removed outreach candidate ${req.params.postId} from the funnel`; ctx.activity.push('info', message); ctx.broadcaster.push('warn', message); return res.json({ success: true, postId: req.params.postId }); }); return router; }