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/',