Add outreach candidate editing controls
This commit is contained in:
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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user