Add recent outreach rejections stream

This commit is contained in:
jjbrunton committed 2026-08-14 15:45:57 +01:00
1 parent 0c21202253
commit 94049ed9c4
9 files changed
+170 -16

No files matched your search

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { OutreachCandidate, OutreachCandidateStatus } from '../../../../../packages/api-contracts/src/index.js'; import type { OutreachCandidate, OutreachCandidateStatus, OutreachRejectedCandidate } from '../../../../../packages/api-contracts/src/index.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js'; import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import { OriginPostCard } from '../../components/origin-post.js'; import { OriginPostCard } from '../../components/origin-post.js';
import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { RedditUsernameLink } from '../../components/reddit-username-link.js';
@@ -11,7 +11,7 @@ import { DeleteCandidateDialog } from './delete-candidate-dialog.js';
import { EditMessageDialog } from './edit-message-dialog.js'; import { EditMessageDialog } from './edit-message-dialog.js';
import './outreach.css'; import './outreach.css';
type OutreachTab = 'active' | 'rejections'; type OutreachTab = 'active' | 'reasons' | 'recent-rejections';
const STAGES: ReadonlyArray<{ const STAGES: ReadonlyArray<{
status: OutreachCandidateStatus; status: OutreachCandidateStatus;
@@ -152,6 +152,29 @@ function CandidateCard({
); );
} }
function RejectedCandidateCard({ candidate, now }: { candidate: OutreachRejectedCandidate; now: number }) {
return (
<li className="rejected-candidate">
<div className="rejected-candidate-heading">
<div className="candidate-labels">
<span className="candidate-status status-rejected">Rejected</span>
<span>r/{candidate.subreddit}</span>
<span>{candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}</span>
</div>
<time dateTime={new Date(candidate.rejectedAt).toISOString()} title={formatExactTime(candidate.rejectedAt)}>
{formatShortRelativeTime(candidate.rejectedAt, now)}
</time>
</div>
<a className="candidate-title" href={candidate.postUrl} rel="noreferrer" target="_blank">
{candidate.title} <span aria-hidden="true">↗</span>
</a>
<RedditUsernameLink username={candidate.author} />
{candidate.sourceContent ? <blockquote className="rejected-source-content">{candidate.sourceContent}</blockquote> : null}
<p className="rejection-reason"><strong>Rejected because</strong>{candidate.reason}</p>
</li>
);
}
export function OutreachPage() { export function OutreachPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const query = useQuery({ const query = useQuery({
@@ -177,16 +200,20 @@ export function OutreachPage() {
const [actionNotice, setActionNotice] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<OutreachTab>('active'); const [selectedTab, setSelectedTab] = useState<OutreachTab>('active');
const activeTabRef = useRef<HTMLButtonElement>(null); const activeTabRef = useRef<HTMLButtonElement>(null);
const rejectionsTabRef = useRef<HTMLButtonElement>(null); const reasonsTabRef = useRef<HTMLButtonElement>(null);
const recentRejectionsTabRef = useRef<HTMLButtonElement>(null);
const selectTab = (tab: OutreachTab, focus = false) => { const selectTab = (tab: OutreachTab, focus = false) => {
setSelectedTab(tab); setSelectedTab(tab);
if (focus) (tab === 'active' ? activeTabRef : rejectionsTabRef).current?.focus(); if (focus) ({ active: activeTabRef, reasons: reasonsTabRef, 'recent-rejections': recentRejectionsTabRef }[tab]).current?.focus();
}; };
const handleTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => { const handleTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'Home' && event.key !== 'End') return; if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'Home' && event.key !== 'End') return;
event.preventDefault(); event.preventDefault();
selectTab(event.key === 'ArrowLeft' || event.key === 'Home' ? 'active' : 'rejections', true); const tabs: OutreachTab[] = ['active', 'reasons', 'recent-rejections'];
const currentIndex = tabs.indexOf(selectedTab);
const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (currentIndex + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length;
selectTab(tabs[nextIndex], true);
}; };
// Single client-side clock for every displayed offset: retry countdowns tick // Single client-side clock for every displayed offset: retry countdowns tick
@@ -289,17 +316,31 @@ export function OutreachPage() {
</button> </button>
<button <button
aria-controls="outreach-rejections-panel" aria-controls="outreach-rejections-panel"
aria-selected={selectedTab === 'rejections'} aria-selected={selectedTab === 'reasons'}
className="outreach-tab" className="outreach-tab"
id="outreach-rejections-tab" id="outreach-reasons-tab"
onClick={() => selectTab('rejections')} onClick={() => selectTab('reasons')}
onKeyDown={handleTabKeyDown} onKeyDown={handleTabKeyDown}
ref={rejectionsTabRef} ref={reasonsTabRef}
role="tab" role="tab"
tabIndex={selectedTab === 'rejections' ? 0 : -1} tabIndex={selectedTab === 'reasons' ? 0 : -1}
type="button" type="button"
> >
Rejections <span>{data.outcomes.rejected}</span> Rejection reasons <span>{data.outcomes.rejected}</span>
</button>
<button
aria-controls="outreach-recent-rejections-panel"
aria-selected={selectedTab === 'recent-rejections'}
className="outreach-tab"
id="outreach-recent-rejections-tab"
onClick={() => selectTab('recent-rejections')}
onKeyDown={handleTabKeyDown}
ref={recentRejectionsTabRef}
role="tab"
tabIndex={selectedTab === 'recent-rejections' ? 0 : -1}
type="button"
>
Recent rejections <span>{data.recentRejections.length}</span>
</button> </button>
</div> </div>
@@ -328,8 +369,8 @@ export function OutreachPage() {
</ol> </ol>
)} )}
</section> </section>
) : ( ) : selectedTab === 'reasons' ? (
<section aria-labelledby="outreach-rejections-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel"> <section aria-labelledby="outreach-reasons-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
<div className="panel-heading"> <div className="panel-heading">
<div><span className="eyebrow">Terminal screening outcomes</span><h2>Rejections</h2></div> <div><span className="eyebrow">Terminal screening outcomes</span><h2>Rejections</h2></div>
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span> <span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
@@ -344,6 +385,20 @@ export function OutreachPage() {
</ol> </ol>
)} )}
</section> </section>
) : (
<section aria-labelledby="outreach-recent-rejections-tab" className="rejection-section" id="outreach-recent-rejections-panel" role="tabpanel">
<div className="panel-heading">
<div><span className="eyebrow">Latest 50 terminal outcomes</span><h2>Recent rejections</h2></div>
<span className="candidate-updated">Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s</span>
</div>
{data.recentRejections.length === 0 ? (
<div className="empty-state panel"><strong>No rejected candidates yet.</strong><span>The most recent 50 screening rejections will appear here with their source and reason.</span></div>
) : (
<ol aria-label="Recent rejected outreach candidates" className="recent-rejections">
{data.recentRejections.map((candidate) => <RejectedCandidateCard candidate={candidate} key={candidate.postId} now={now} />)}
</ol>
)}
</section>
)} )}
{isConfirmingClear ? ( {isConfirmingClear ? (
<ClearFunnelDialog <ClearFunnelDialog
@@ -30,6 +30,14 @@
.rejection-reasons li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: .8rem .9rem; color: #e7d9b1; background: #211b12; border: 1px solid #4c3b1d; border-left: 3px solid var(--warning); border-radius: .65rem; } .rejection-reasons li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: .8rem .9rem; color: #e7d9b1; background: #211b12; border: 1px solid #4c3b1d; border-left: 3px solid var(--warning); border-radius: .65rem; }
.rejection-reasons span { overflow-wrap: anywhere; } .rejection-reasons span { overflow-wrap: anywhere; }
.rejection-reasons strong { min-width: 2rem; color: var(--warning-text); font-size: 1.1rem; text-align: right; } .rejection-reasons strong { min-width: 2rem; color: var(--warning-text); font-size: 1.1rem; text-align: right; }
.recent-rejections { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
.rejected-candidate { display: grid; gap: .65rem; padding: .9rem 1rem; background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--warning); border-radius: .75rem; }
.rejected-candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.rejected-candidate-heading time { flex: 0 0 auto; color: var(--muted); font-size: .74rem; }
.status-rejected { color: var(--warning-text); background: #42351b; }
.rejected-source-content { margin: 0; padding: .65rem .75rem; color: #dce8e2; background: #111915; border-left: 3px solid #426655; border-radius: .35rem; white-space: pre-wrap; overflow-wrap: anywhere; }
.rejection-reason { display: grid; gap: .18rem; margin: 0; padding: .55rem .7rem; color: #e7d9b1; background: #211b12; border-left: 2px solid var(--warning); border-radius: .35rem; overflow-wrap: anywhere; }
.rejection-reason strong { color: var(--warning-text); font-size: .68rem; letter-spacing: .06em; text-transform: uppercase; }
.candidate-updated { color: var(--muted); font-size: .8rem; } .candidate-updated { color: var(--muted); font-size: .8rem; }
.outreach-candidates { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; } .outreach-candidates { display: grid; gap: .7rem; padding: 0; margin: 0; list-style: none; }
.outreach-candidate { display: grid; grid-template-columns: .32rem minmax(0, 1fr); overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; } .outreach-candidate { display: grid; grid-template-columns: .32rem minmax(0, 1fr); overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .85rem; }
+1 -1
View File
@@ -36,7 +36,7 @@ Prepared cards in the authenticated outreach funnel expose the exact opener, gro
The funnel header also offers a destructive, confirmed **Clear funnel** action. Its dialog names the number of cancellable candidates, explains that audit rows remain and running discovery can refill the queue, and separately identifies any in-flight sends that cannot safely be cancelled. Success is reported from the authoritative API response before the funnel query refreshes. The funnel header also offers a destructive, confirmed **Clear funnel** action. Its dialog names the number of cancellable candidates, explains that audit rows remain and running discovery can refill the queue, and separately identifies any in-flight sends that cannot safely be cancelled. Success is reported from the authoritative API response before the funnel query refreshes.
The outreach funnel separates the live queue from its terminal screening outcomes. Its **Rejections** tab groups durable rejected candidates by their recorded reason, so an empty queue can be diagnosed without exposing the rejected post content or treating a rejection as an operator action. The outreach funnel separates the live queue from its terminal screening outcomes. Its **Rejection reasons** tab groups durable rejected candidates by their recorded reason, while **Recent rejections** is a latest-50 audit stream with the scanned source content, author, timestamp, and recorded reason. Candidates manually cleared or removed by an operator remain counted in the reason summary but are excluded from the scan stream. Both are read-only diagnostic views.
Each non-sending candidate has a confirmed **Remove** action. Prepared candidates in ready or retrying state also have **Edit message**: the modal validates the 1–2000 character limit, the server re-applies outbound safeguards, and a successful edit is shown as operator-authored attribution. Buttons are intentionally absent once the candidate is sending, while the server still enforces the state boundary against stale pages. Each non-sending candidate has a confirmed **Remove** action. Prepared candidates in ready or retrying state also have **Edit message**: the modal validates the 1–2000 character limit, the server re-applies outbound safeguards, and a successful edit is shown as operator-authored attribution. Buttons are intentionally absent once the candidate is sending, while the server still enforces the state boundary against stale pages.
+14
View File
@@ -64,6 +64,18 @@ export const OutreachCandidateSchema = z.strictObject({
owner: RedditAccountOwnerSchema.nullable(), owner: RedditAccountOwnerSchema.nullable(),
}); });
export const OutreachRejectedCandidateSchema = z.strictObject({
postId: z.string(),
subreddit: z.string(),
author: z.string(),
title: z.string(),
postUrl: z.string(),
sourceType: z.enum(['image', 'chat']),
sourceContent: z.string().nullable(),
rejectedAt: z.number(),
reason: z.string(),
});
export const OutreachFunnelResponseSchema = z.strictObject({ export const OutreachFunnelResponseSchema = z.strictObject({
generatedAt: z.number(), generatedAt: z.number(),
activeCount: z.number().int().nonnegative(), activeCount: z.number().int().nonnegative(),
@@ -76,6 +88,7 @@ export const OutreachFunnelResponseSchema = z.strictObject({
reason: z.string(), reason: z.string(),
count: z.number().int().positive(), count: z.number().int().positive(),
})).default([]), })).default([]),
recentRejections: z.array(OutreachRejectedCandidateSchema).max(50).default([]),
oldestActiveAt: z.number().nullable(), oldestActiveAt: z.number().nullable(),
candidates: z.array(OutreachCandidateSchema), candidates: z.array(OutreachCandidateSchema),
}); });
@@ -109,6 +122,7 @@ export const OutreachCandidateDeleteResponseSchema = z.strictObject({
export type OutreachCandidateStatus = z.infer<typeof OutreachCandidateStatusSchema>; export type OutreachCandidateStatus = z.infer<typeof OutreachCandidateStatusSchema>;
export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>; export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>;
export type OutreachRejectedCandidate = z.infer<typeof OutreachRejectedCandidateSchema>;
export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>; export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>;
export type OutreachClearResponse = z.infer<typeof OutreachClearResponseSchema>; export type OutreachClearResponse = z.infer<typeof OutreachClearResponseSchema>;
export type OutreachMessageUpdateRequest = z.infer<typeof OutreachMessageUpdateRequestSchema>; export type OutreachMessageUpdateRequest = z.infer<typeof OutreachMessageUpdateRequestSchema>;
@@ -480,6 +480,21 @@ export class CandidateQueueRepository {
return rows.map((row) => this.mapRow(row)); return rows.map((row) => this.mapRow(row));
} }
/** Most recently completed screening rejections for the operator audit stream. */
listRecentRejected(limit = 50): CandidateQueueRecord[] {
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
throw new Error('Rejected candidate limit must be an integer between 1 and 50');
}
const rows = this.db.prepare(`
SELECT * FROM candidate_queue
WHERE status = 'rejected'
AND COALESCE(last_error, '') NOT IN ('Cleared manually by operator', 'Removed manually by operator')
ORDER BY updated_at DESC, post_created_at DESC
LIMIT ?
`).all(limit) as Array<Record<string, unknown>>;
return rows.map((row) => this.mapRow(row));
}
private claim( private claim(
from: 'discovered' | 'ready', from: 'discovered' | 'ready',
to: 'screening' | 'sending', to: 'screening' | 'sending',
+25
View File
@@ -45,6 +45,18 @@ interface OutreachCandidateView {
owner: { accountId: string; redditUsername: string } | null; owner: { accountId: string; redditUsername: string } | null;
} }
interface OutreachRejectedCandidateView {
postId: string;
subreddit: string;
author: string;
title: string;
postUrl: string;
sourceType: 'image' | 'chat';
sourceContent: string | null;
rejectedAt: number;
reason: string;
}
function redditPostUrl(permalink: string): string { function redditPostUrl(permalink: string): string {
try { try {
return new URL(permalink, 'https://www.reddit.com').toString(); return new URL(permalink, 'https://www.reddit.com').toString();
@@ -65,6 +77,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
const stats = queue.getStats(); const stats = queue.getStats();
const records = queue.listActive(); const records = queue.listActive();
const rejectedRecords = queue.listRecentRejected();
const accountById = new Map((ctx.services.redditAccounts?.list() ?? []) const accountById = new Map((ctx.services.redditAccounts?.list() ?? [])
.map((account) => [account.accountId, account.redditUsername] as const)); .map((account) => [account.accountId, account.redditUsername] as const));
const candidates: OutreachCandidateView[] = records.map((candidate) => ({ const candidates: OutreachCandidateView[] = records.map((candidate) => ({
@@ -116,6 +129,17 @@ export function createOutreachRouter(ctx: WebContext): Router {
} }
: null, : null,
})); }));
const recentRejections: OutreachRejectedCandidateView[] = rejectedRecords.map((candidate) => ({
postId: candidate.postId,
subreddit: candidate.subreddit,
author: candidate.author,
title: candidate.post.title,
postUrl: redditPostUrl(candidate.post.permalink),
sourceType: candidate.sourceType,
sourceContent: candidate.post.targetComment?.body ?? (candidate.post.selftext?.trim() || null),
rejectedAt: candidate.updatedAt,
reason: candidate.lastError ?? 'Unknown',
}));
const stages = { const stages = {
discovered: stats.byStatus.discovered, discovered: stats.byStatus.discovered,
screening: stats.byStatus.screening, screening: stats.byStatus.screening,
@@ -129,6 +153,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
stages, stages,
outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected }, outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected },
rejectionReasons: stats.rejectionReasons, rejectionReasons: stats.rejectionReasons,
recentRejections,
oldestActiveAt: queue.getOldestActiveAt(), oldestActiveAt: queue.getOldestActiveAt(),
candidates, candidates,
}; };
+15
View File
@@ -245,6 +245,21 @@ describe('CandidateQueueRepository', () => {
expect(reasons).toEqual([{ last_error: 'Cleared manually by operator' }]); expect(reasons).toEqual([{ last_error: 'Cleared manually by operator' }]);
}); });
it('lists the latest 50 rejected candidates by their terminal update time', () => {
queue.enqueue([post('older', 'alice'), post('newer', 'bob')], 'image', 1, 1_000);
const older = queue.claimForScreening('older-worker', 1_000, 1_001)!;
queue.reject(older.postId, 'older-worker', 'No gender tag in title/body', 1_002);
const newer = queue.claimForScreening('newer-worker', 1_000, 1_003)!;
queue.reject(newer.postId, 'newer-worker', 'Seller signals detected', 1_004);
queue.enqueue([post('manual', 'carol')], 'image', 1, 1_005);
queue.cancelUnsent('manual', 1_006);
expect(queue.listRecentRejected()).toEqual([
expect.objectContaining({ postId: 'newer', lastError: 'Seller signals detected', updatedAt: 1_004 }),
expect.objectContaining({ postId: 'older', lastError: 'No gender tag in title/body', updatedAt: 1_002 }),
]);
});
it('edits only prepared unsent messages and clears generated attribution', () => { it('edits only prepared unsent messages and clears generated attribution', () => {
queue.enqueue([post('editable', 'alice', 200), post('waiting', 'bob', 100)], 'image', 1, 1_000); queue.enqueue([post('editable', 'alice', 200), post('waiting', 'bob', 100)], 'image', 1, 1_000);
const editable = queue.claimForScreening('screen', 1_000, 1_001)!; const editable = queue.claimForScreening('screen', 1_000, 1_001)!;
+11 -2
View File
@@ -130,6 +130,11 @@ describe('dashboard foundation', () => {
stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 }, stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 },
outcomes: { sent: 12, rejected: 7 }, outcomes: { sent: 12, rejected: 7 },
rejectionReasons: [{ reason: 'Seller signals detected', count: 5 }, { reason: 'No gender tag in title/body', count: 2 }], rejectionReasons: [{ reason: 'Seller signals detected', count: 5 }, { reason: 'No gender tag in title/body', count: 2 }],
recentRejections: [{
postId: 'rejected-1', subreddit: 'example', author: 'bob', title: '[24F] Rejected post',
postUrl: 'https://www.reddit.com/r/example/comments/rejected-1/', sourceType: 'chat',
sourceContent: 'This is the content that was screened.', rejectedAt: Date.parse('2026-08-09T07:45:00Z'), reason: 'Seller signals detected',
}],
oldestActiveAt: Date.parse('2026-08-09T07:00:00Z'), oldestActiveAt: Date.parse('2026-08-09T07:00:00Z'),
candidates: [{ candidates: [{
postId: 'candidate-1', subreddit: 'example', author: 'alice', title: '[25F] A potential post', postId: 'candidate-1', subreddit: 'example', author: 'alice', title: '[25F] A potential post',
@@ -170,9 +175,12 @@ describe('dashboard foundation', () => {
expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument(); expect(await screen.findByRole('heading', { name: 'Outreach funnel' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: 'Rejections 7' })); await user.click(screen.getByRole('tab', { name: 'Rejection reasons 7' }));
expect(screen.getByRole('tabpanel', { name: 'Rejections 7' })).toHaveTextContent('Seller signals detected'); expect(screen.getByRole('tabpanel', { name: 'Rejection reasons 7' })).toHaveTextContent('Seller signals detected');
expect(screen.getByRole('list', { name: 'Outreach rejection reasons' })).toHaveTextContent('5'); expect(screen.getByRole('list', { name: 'Outreach rejection reasons' })).toHaveTextContent('5');
await user.click(screen.getByRole('tab', { name: 'Recent rejections 1' }));
expect(screen.getByRole('list', { name: 'Recent rejected outreach candidates' })).toHaveTextContent('This is the content that was screened.');
expect(screen.getByText('Rejected because')).toBeInTheDocument();
await user.click(screen.getByRole('tab', { name: 'Active queue 3' })); await user.click(screen.getByRole('tab', { name: 'Active queue 3' }));
expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post'); expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('A potential post');
expect(screen.getAllByRole('link', { name: '[25F] A potential post' })) expect(screen.getAllByRole('link', { name: '[25F] A potential post' }))
@@ -225,6 +233,7 @@ describe('dashboard foundation', () => {
stages: { discovered: cleared ? 0 : 2, screening: 0, ready: 0, sending: 1, retryable: 0 }, stages: { discovered: cleared ? 0 : 2, screening: 0, ready: 0, sending: 1, retryable: 0 },
outcomes: { sent: 12, rejected: cleared ? 9 : 7 }, outcomes: { sent: 12, rejected: cleared ? 9 : 7 },
rejectionReasons: [{ reason: 'Cleared manually by operator', count: cleared ? 2 : 0 }].filter(({ count }) => count > 0), rejectionReasons: [{ reason: 'Cleared manually by operator', count: cleared ? 2 : 0 }].filter(({ count }) => count > 0),
recentRejections: [],
oldestActiveAt: Date.now() - 60_000, oldestActiveAt: Date.now() - 60_000,
candidates: [], candidates: [],
})), })),
+13
View File
@@ -35,6 +35,14 @@ describe('outreach route', () => {
rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }], rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }],
})), })),
getOldestActiveAt: vi.fn(() => 1_000), getOldestActiveAt: vi.fn(() => 1_000),
listRecentRejected: vi.fn(() => [{
postId: 'rejected-1', subreddit: 'example', author: 'bob', sourceType: 'chat',
post: {
id: 'rejected-1', subreddit: 'example', author: 'bob', title: 'A rejected post',
permalink: '/r/example/comments/rejected-1/', selftext: 'Scanned post content',
},
updatedAt: 3_000, lastError: 'Seller signals detected',
}]),
listActive: vi.fn(() => [{ listActive: vi.fn(() => [{
postId: 'post-1', subreddit: 'example', author: 'alice', sourceType: 'image', postId: 'post-1', subreddit: 'example', author: 'alice', sourceType: 'image',
post: { post: {
@@ -89,6 +97,11 @@ describe('outreach route', () => {
stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 }, stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 },
outcomes: { sent: 2, rejected: 1 }, outcomes: { sent: 2, rejected: 1 },
rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }], rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }],
recentRejections: [{
postId: 'rejected-1', subreddit: 'example', author: 'bob', title: 'A rejected post',
postUrl: 'https://www.reddit.com/r/example/comments/rejected-1/', sourceType: 'chat',
sourceContent: 'Scanned post content', rejectedAt: 3_000, reason: 'Seller signals detected',
}],
candidates: [{ candidates: [{
postId: 'post-1', status: 'sending', hasPreparedMessage: true, postId: 'post-1', status: 'sending', hasPreparedMessage: true,
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/', postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',