Add outreach candidate editing controls

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:12:54 +01:00
1 parent c75e71f26e
commit 79c508be2b
19 files changed
+434 -12

No files matched your search

+37
View File
@@ -1,8 +1,11 @@
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 {
@@ -96,6 +99,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
modelId: candidate.prepared.modelId,
toneId: candidate.prepared.toneId,
scripted: candidate.prepared.scripted,
operatorEdited: candidate.prepared.operatorEdited ?? false,
} : null,
note: candidate.lastError,
owner: candidate.assignedAccountId
@@ -148,5 +152,38 @@ export function createOutreachRouter(ctx: WebContext): Router {
});
});
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;
}