Add recent outreach rejections stream
This commit is contained in:
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 { 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 (
|
||||
<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() {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useQuery({
|
||||
@@ -177,16 +200,20 @@ export function OutreachPage() {
|
||||
const [actionNotice, setActionNotice] = useState<string | null>(null);
|
||||
const [selectedTab, setSelectedTab] = useState<OutreachTab>('active');
|
||||
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) => {
|
||||
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>) => {
|
||||
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() {
|
||||
</button>
|
||||
<button
|
||||
aria-controls="outreach-rejections-panel"
|
||||
aria-selected={selectedTab === 'rejections'}
|
||||
aria-selected={selectedTab === 'reasons'}
|
||||
className="outreach-tab"
|
||||
id="outreach-rejections-tab"
|
||||
onClick={() => selectTab('rejections')}
|
||||
id="outreach-reasons-tab"
|
||||
onClick={() => selectTab('reasons')}
|
||||
onKeyDown={handleTabKeyDown}
|
||||
ref={rejectionsTabRef}
|
||||
ref={reasonsTabRef}
|
||||
role="tab"
|
||||
tabIndex={selectedTab === 'rejections' ? 0 : -1}
|
||||
tabIndex={selectedTab === 'reasons' ? 0 : -1}
|
||||
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>
|
||||
</div>
|
||||
|
||||
@@ -328,8 +369,8 @@ export function OutreachPage() {
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section aria-labelledby="outreach-rejections-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
|
||||
) : selectedTab === 'reasons' ? (
|
||||
<section aria-labelledby="outreach-reasons-tab" className="rejection-section" id="outreach-rejections-panel" role="tabpanel">
|
||||
<div className="panel-heading">
|
||||
<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>
|
||||
@@ -344,6 +385,20 @@ export function OutreachPage() {
|
||||
</ol>
|
||||
)}
|
||||
</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 ? (
|
||||
<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 span { overflow-wrap: anywhere; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
||||
+1
-1
@@ -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 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.
|
||||
|
||||
|
||||
@@ -64,6 +64,18 @@ export const OutreachCandidateSchema = z.strictObject({
|
||||
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({
|
||||
generatedAt: z.number(),
|
||||
activeCount: z.number().int().nonnegative(),
|
||||
@@ -76,6 +88,7 @@ export const OutreachFunnelResponseSchema = z.strictObject({
|
||||
reason: z.string(),
|
||||
count: z.number().int().positive(),
|
||||
})).default([]),
|
||||
recentRejections: z.array(OutreachRejectedCandidateSchema).max(50).default([]),
|
||||
oldestActiveAt: z.number().nullable(),
|
||||
candidates: z.array(OutreachCandidateSchema),
|
||||
});
|
||||
@@ -109,6 +122,7 @@ export const OutreachCandidateDeleteResponseSchema = z.strictObject({
|
||||
|
||||
export type OutreachCandidateStatus = z.infer<typeof OutreachCandidateStatusSchema>;
|
||||
export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>;
|
||||
export type OutreachRejectedCandidate = z.infer<typeof OutreachRejectedCandidateSchema>;
|
||||
export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>;
|
||||
export type OutreachClearResponse = z.infer<typeof OutreachClearResponseSchema>;
|
||||
export type OutreachMessageUpdateRequest = z.infer<typeof OutreachMessageUpdateRequestSchema>;
|
||||
|
||||
@@ -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<Record<string, unknown>>;
|
||||
return rows.map((row) => this.mapRow(row));
|
||||
}
|
||||
|
||||
private claim(
|
||||
from: 'discovered' | 'ready',
|
||||
to: 'screening' | 'sending',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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: [],
|
||||
})),
|
||||
|
||||
@@ -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/',
|
||||
|
||||
Reference in new issue
Block a user