198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
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<CandidateStatus, 'sent' | 'rejected'>;
|
||
|
||
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;
|
||
}
|