Add manual outreach funnel clearing
This commit is contained in:
1 parent
6b2e1bc380
commit
4798385d8b
16 files changed
+319
-17
No files matched your search
@@ -55,6 +55,15 @@ export interface PhysiqueTargetingReconciliation {
|
||||
retained: number;
|
||||
}
|
||||
|
||||
export interface CandidateQueueClearResult {
|
||||
discovered: number;
|
||||
screening: number;
|
||||
ready: number;
|
||||
retryable: number;
|
||||
total: number;
|
||||
retainedSending: number;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable'];
|
||||
|
||||
/** Global, durable funnel from Reddit listing discovery through outbound delivery. */
|
||||
@@ -251,6 +260,34 @@ export class CandidateQueueRepository {
|
||||
`).run(now, now + sendingDelayMs, now).changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels every candidate that cannot already be crossing the remote-send
|
||||
* boundary. Sending leases are deliberately retained because their Matrix
|
||||
* outcome may be unknown while this transaction runs.
|
||||
*/
|
||||
clearUnsent(now: number = Date.now()): CandidateQueueClearResult {
|
||||
return this.db.transaction(() => {
|
||||
const statuses = ['discovered', 'screening', 'ready', 'retryable'] as const;
|
||||
const counts = Object.fromEntries(statuses.map((status) => [status, 0])) as Record<(typeof statuses)[number], number>;
|
||||
const rows = this.db.prepare(`
|
||||
SELECT status, COUNT(*) AS count FROM candidate_queue
|
||||
WHERE status IN (${statuses.map(() => '?').join(', ')})
|
||||
GROUP BY status
|
||||
`).all(...statuses) as Array<{ status: (typeof statuses)[number]; count: number }>;
|
||||
for (const row of rows) counts[row.status] = row.count;
|
||||
const retainedSending = (this.db.prepare(
|
||||
`SELECT COUNT(*) AS count FROM candidate_queue WHERE status = 'sending'`,
|
||||
).get() as { count: number }).count;
|
||||
const changed = this.db.prepare(`
|
||||
UPDATE candidate_queue
|
||||
SET status = 'rejected', last_error = 'Cleared manually by operator', updated_at = ?,
|
||||
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL
|
||||
WHERE status IN (${statuses.map(() => '?').join(', ')})
|
||||
`).run(now, ...statuses).changes;
|
||||
return { ...counts, total: changed, retainedSending };
|
||||
})();
|
||||
}
|
||||
|
||||
/** Re-evaluates prepared image candidates that have not yet been leased for sending. */
|
||||
reconcilePhysiqueTargeting(
|
||||
targeting: PhysiqueTargeting,
|
||||
|
||||
@@ -179,6 +179,20 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
operation('getAiUsageAnalytics', 'GET', '/api/analytics/ai-usage', 'Get OpenRouter cost and model reliability', ['Analytics']),
|
||||
operation('getAttention', 'GET', '/api/attention', 'Get operator attention items', ['Operations']),
|
||||
operation('getOutreachFunnel', 'GET', '/api/outreach', 'Get active outreach candidates and funnel stages', ['Operations']),
|
||||
operation('clearOutreachFunnel', 'DELETE', '/api/outreach', 'Cancel every unsent outreach candidate except in-flight sends', ['Operations'], {
|
||||
access: 'admin',
|
||||
responseSchema: z.strictObject({
|
||||
success: z.literal(true),
|
||||
cleared: z.strictObject({
|
||||
discovered: z.number().int().nonnegative(),
|
||||
screening: z.number().int().nonnegative(),
|
||||
ready: z.number().int().nonnegative(),
|
||||
retryable: z.number().int().nonnegative(),
|
||||
total: z.number().int().nonnegative(),
|
||||
}),
|
||||
retainedSending: z.number().int().nonnegative(),
|
||||
}),
|
||||
}),
|
||||
operation('getGrowthOverview', 'GET', '/api/growth', 'Get per-account comment growth state', ['Growth'], {
|
||||
responseSchema: GrowthOverviewResponseSchema,
|
||||
}),
|
||||
|
||||
@@ -123,5 +123,30 @@ export function createOutreachRouter(ctx: WebContext): Router {
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in new issue
Block a user