diff --git a/apps/dashboard/src/features/outreach/outreach-page.tsx b/apps/dashboard/src/features/outreach/outreach-page.tsx
index 37ab8f0..64ab465 100644
--- a/apps/dashboard/src/features/outreach/outreach-page.tsx
+++ b/apps/dashboard/src/features/outreach/outreach-page.tsx
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react';
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 { OriginPostCard } from '../../components/origin-post.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 './outreach.css';
-type OutreachTab = 'active' | 'rejections';
+type OutreachTab = 'active' | 'reasons' | 'recent-rejections';
const STAGES: ReadonlyArray<{
status: OutreachCandidateStatus;
@@ -152,6 +152,29 @@ function CandidateCard({
);
}
+function RejectedCandidateCard({ candidate, now }: { candidate: OutreachRejectedCandidate; now: number }) {
+ return (
+
+
+
+ Rejected
+ r/{candidate.subreddit}
+ {candidate.sourceType === 'image' ? 'Image post' : 'Chat post'}
+
+
+
+
+ {candidate.title} ↗
+
+
+ {candidate.sourceContent ? {candidate.sourceContent}
: null}
+ Rejected because{candidate.reason}
+
+ );
+}
+
export function OutreachPage() {
const queryClient = useQueryClient();
const query = useQuery({
@@ -177,16 +200,20 @@ export function OutreachPage() {
const [actionNotice, setActionNotice] = useState(null);
const [selectedTab, setSelectedTab] = useState('active');
const activeTabRef = useRef(null);
- const rejectionsTabRef = useRef(null);
+ const reasonsTabRef = useRef(null);
+ const recentRejectionsTabRef = useRef(null);
const selectTab = (tab: OutreachTab, focus = false) => {
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) => {
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight' && event.key !== 'Home' && event.key !== 'End') return;
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
@@ -289,17 +316,31 @@ export function OutreachPage() {
+
@@ -328,8 +369,8 @@ export function OutreachPage() {
)}
- ) : (
-
+ ) : selectedTab === 'reasons' ? (
+
Terminal screening outcomes
Rejections
Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
@@ -344,6 +385,20 @@ export function OutreachPage() {
)}
+ ) : (
+
+
+
Latest 50 terminal outcomes
Recent rejections
+
Updated {formatShortRelativeTime(data.generatedAt, now)} · Auto-updates every 10s
+
+ {data.recentRejections.length === 0 ? (
+ No rejected candidates yet.The most recent 50 screening rejections will appear here with their source and reason.
+ ) : (
+
+ {data.recentRejections.map((candidate) => )}
+
+ )}
+
)}
{isConfirmingClear ? (
;
export type OutreachCandidate = z.infer;
+export type OutreachRejectedCandidate = z.infer;
export type OutreachFunnelResponse = z.infer;
export type OutreachClearResponse = z.infer;
export type OutreachMessageUpdateRequest = z.infer;
diff --git a/src/database/candidate-queue-repository.ts b/src/database/candidate-queue-repository.ts
index d8c2892..ea81eb8 100644
--- a/src/database/candidate-queue-repository.ts
+++ b/src/database/candidate-queue-repository.ts
@@ -480,6 +480,21 @@ export class CandidateQueueRepository {
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>;
+ return rows.map((row) => this.mapRow(row));
+ }
+
private claim(
from: 'discovered' | 'ready',
to: 'screening' | 'sending',
diff --git a/src/web/routes/outreach.ts b/src/web/routes/outreach.ts
index 61bcc4a..314ac90 100644
--- a/src/web/routes/outreach.ts
+++ b/src/web/routes/outreach.ts
@@ -45,6 +45,18 @@ interface OutreachCandidateView {
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 {
try {
return new URL(permalink, 'https://www.reddit.com').toString();
@@ -65,6 +77,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
const stats = queue.getStats();
const records = queue.listActive();
+ const rejectedRecords = queue.listRecentRejected();
const accountById = new Map((ctx.services.redditAccounts?.list() ?? [])
.map((account) => [account.accountId, account.redditUsername] as const));
const candidates: OutreachCandidateView[] = records.map((candidate) => ({
@@ -116,6 +129,17 @@ export function createOutreachRouter(ctx: WebContext): Router {
}
: 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 = {
discovered: stats.byStatus.discovered,
screening: stats.byStatus.screening,
@@ -129,6 +153,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
stages,
outcomes: { sent: stats.byStatus.sent, rejected: stats.byStatus.rejected },
rejectionReasons: stats.rejectionReasons,
+ recentRejections,
oldestActiveAt: queue.getOldestActiveAt(),
candidates,
};
diff --git a/tests/candidate-queue.test.ts b/tests/candidate-queue.test.ts
index 2b53e55..d87eee0 100644
--- a/tests/candidate-queue.test.ts
+++ b/tests/candidate-queue.test.ts
@@ -245,6 +245,21 @@ describe('CandidateQueueRepository', () => {
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', () => {
queue.enqueue([post('editable', 'alice', 200), post('waiting', 'bob', 100)], 'image', 1, 1_000);
const editable = queue.claimForScreening('screen', 1_000, 1_001)!;
diff --git a/tests/dashboard/app.test.tsx b/tests/dashboard/app.test.tsx
index aa9b6a0..91020b0 100644
--- a/tests/dashboard/app.test.tsx
+++ b/tests/dashboard/app.test.tsx
@@ -130,6 +130,11 @@ describe('dashboard foundation', () => {
stages: { discovered: 1, screening: 0, ready: 1, sending: 0, retryable: 1 },
outcomes: { sent: 12, rejected: 7 },
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'),
candidates: [{
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(screen.getByRole('heading', { name: '3 active posts' })).toBeInTheDocument();
- await user.click(screen.getByRole('tab', { name: 'Rejections 7' }));
- expect(screen.getByRole('tabpanel', { name: 'Rejections 7' })).toHaveTextContent('Seller signals detected');
+ await user.click(screen.getByRole('tab', { name: 'Rejection reasons 7' }));
+ expect(screen.getByRole('tabpanel', { name: 'Rejection reasons 7' })).toHaveTextContent('Seller signals detected');
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' }));
expect(screen.getByRole('list', { name: 'Active outreach candidates' })).toHaveTextContent('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 },
outcomes: { sent: 12, rejected: cleared ? 9 : 7 },
rejectionReasons: [{ reason: 'Cleared manually by operator', count: cleared ? 2 : 0 }].filter(({ count }) => count > 0),
+ recentRejections: [],
oldestActiveAt: Date.now() - 60_000,
candidates: [],
})),
diff --git a/tests/web/outreach-route.test.ts b/tests/web/outreach-route.test.ts
index e8a8837..d9d8c9d 100644
--- a/tests/web/outreach-route.test.ts
+++ b/tests/web/outreach-route.test.ts
@@ -35,6 +35,14 @@ describe('outreach route', () => {
rejectionReasons: [{ reason: 'Seller signals detected', count: 1 }],
})),
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(() => [{
postId: 'post-1', subreddit: 'example', author: 'alice', sourceType: 'image',
post: {
@@ -89,6 +97,11 @@ describe('outreach route', () => {
stages: { discovered: 2, screening: 1, ready: 1, sending: 1, retryable: 1 },
outcomes: { sent: 2, rejected: 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: [{
postId: 'post-1', status: 'sending', hasPreparedMessage: true,
postUrl: 'https://www.reddit.com/r/example/comments/post-1/hello/',