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

@@ -64,6 +64,9 @@ export interface CandidateQueueClearResult {
retainedSending: number;
}
export type CandidateEditResult = 'updated' | 'not-found' | 'not-editable';
export type CandidateCancelResult = 'cancelled' | 'not-found' | 'not-cancellable';
const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable'];
/** Global, durable funnel from Reddit listing discovery through outbound delivery. */
@@ -288,6 +291,50 @@ export class CandidateQueueRepository {
})();
}
updatePreparedMessage(postId: string, message: string, now: number = Date.now()): CandidateEditResult {
return this.db.transaction(() => {
const row = this.db.prepare(`SELECT status, prepared_json FROM candidate_queue WHERE post_id = ?`)
.get(postId) as { status: CandidateStatus; prepared_json: string | null } | undefined;
if (!row) return 'not-found';
if (!['ready', 'retryable'].includes(row.status) || !row.prepared_json) return 'not-editable';
const prepared = JSON.parse(row.prepared_json) as ProcessedPost;
const edited: ProcessedPost = {
...prepared,
generatedMessage: message,
strategyId: null,
experimentId: null,
promptVersion: null,
selectionReason: null,
modelId: 'operator-edited',
operatorEdited: true,
};
const changed = this.db.prepare(`
UPDATE candidate_queue SET prepared_json = ?, updated_at = ?
WHERE post_id = ? AND status IN ('ready', 'retryable')
`).run(JSON.stringify(edited), now, postId).changes;
if (changed === 1) {
this.db.prepare(`UPDATE processed_posts SET generated_message = ? WHERE id = ?`).run(message, postId);
}
return changed === 1 ? 'updated' : 'not-editable';
})();
}
cancelUnsent(postId: string, now: number = Date.now()): CandidateCancelResult {
return this.db.transaction(() => {
const row = this.db.prepare(`SELECT status FROM candidate_queue WHERE post_id = ?`)
.get(postId) as { status: CandidateStatus } | undefined;
if (!row) return 'not-found';
if (!['discovered', 'screening', 'ready', 'retryable'].includes(row.status)) return 'not-cancellable';
const changed = this.db.prepare(`
UPDATE candidate_queue
SET status = 'rejected', last_error = 'Removed manually by operator', updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL
WHERE post_id = ? AND status IN ('discovered', 'screening', 'ready', 'retryable')
`).run(now, postId).changes;
return changed === 1 ? 'cancelled' : 'not-cancellable';
})();
}
/** Re-evaluates prepared image candidates that have not yet been leased for sending. */
reconcilePhysiqueTargeting(
targeting: PhysiqueTargeting,
+2
View File
@@ -25,6 +25,8 @@ export interface ProcessedPost {
sourceType: 'image' | 'chat';
toneId: ToneKey;
scripted: boolean;
/** True when an operator replaced the generated opener before dispatch. */
operatorEdited?: boolean;
}
export interface SkipResult {
+9
View File
@@ -193,6 +193,15 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
retainedSending: z.number().int().nonnegative(),
}),
}),
operation('editOutreachMessage', 'PATCH', '/api/outreach/{postId}/message', 'Replace a prepared outreach message before sending', ['Operations'], {
access: 'admin',
requestSchema: z.strictObject({ message: z.string().trim().min(1).max(2000) }),
responseSchema: z.strictObject({ success: z.literal(true), postId: z.string(), message: z.string() }),
}),
operation('deleteOutreachCandidate', 'DELETE', '/api/outreach/{postId}', 'Cancel one unsent outreach candidate', ['Operations'], {
access: 'admin',
responseSchema: z.strictObject({ success: z.literal(true), postId: z.string() }),
}),
operation('getGrowthOverview', 'GET', '/api/growth', 'Get per-account comment growth state', ['Growth'], {
responseSchema: GrowthOverviewResponseSchema,
}),
+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;
}