From 79c508be2b31500bc45325c60cdad0a511a87b5f Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Fri, 14 Aug 2026 09:12:40 +0100 Subject: [PATCH] Add outreach candidate editing controls --- .../outreach/delete-candidate-dialog.tsx | 27 +++++++ .../features/outreach/edit-message-dialog.tsx | 48 ++++++++++++ .../src/features/outreach/outreach-api.ts | 26 +++++++ .../src/features/outreach/outreach-page.tsx | 74 ++++++++++++++++++- .../src/features/outreach/outreach.css | 4 + docs/api.md | 2 + docs/data-flow.md | 2 + docs/database.md | 1 + docs/frontend.md | 4 +- docs/product-and-safety.md | 1 + docs/services.md | 2 +- packages/api-contracts/src/outreach.ts | 19 +++++ src/database/candidate-queue-repository.ts | 47 ++++++++++++ src/services/processor.ts | 2 + src/web/api-catalog.ts | 9 +++ src/web/routes/outreach.ts | 37 ++++++++++ tests/candidate-queue.test.ts | 44 +++++++++++ tests/dashboard/app.test.tsx | 28 +++++++ tests/web/outreach-route.test.ts | 69 +++++++++++++++-- 19 files changed, 434 insertions(+), 12 deletions(-) create mode 100644 apps/dashboard/src/features/outreach/delete-candidate-dialog.tsx create mode 100644 apps/dashboard/src/features/outreach/edit-message-dialog.tsx diff --git a/apps/dashboard/src/features/outreach/delete-candidate-dialog.tsx b/apps/dashboard/src/features/outreach/delete-candidate-dialog.tsx new file mode 100644 index 0000000..6c91b2b --- /dev/null +++ b/apps/dashboard/src/features/outreach/delete-candidate-dialog.tsx @@ -0,0 +1,27 @@ +import type { OutreachCandidate } from '../../../../../packages/api-contracts/src/index.js'; +import { Modal } from '../../components/modal.js'; + +interface DeleteCandidateDialogProps { + candidate: OutreachCandidate; + errorMessage?: string | null; + isPending: boolean; + onCancel(): void; + onConfirm(): void; +} + +export function DeleteCandidateDialog({ candidate, errorMessage = null, isPending, onCancel, onConfirm }: DeleteCandidateDialogProps) { + return ( + + Confirm queue action +

Remove u/{candidate.author} from the funnel?

+

This candidate will be cancelled and retained in the audit history, so the same item will not be rediscovered.

+ {errorMessage ?

{errorMessage}

: null} +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/features/outreach/edit-message-dialog.tsx b/apps/dashboard/src/features/outreach/edit-message-dialog.tsx new file mode 100644 index 0000000..880b0de --- /dev/null +++ b/apps/dashboard/src/features/outreach/edit-message-dialog.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import type { OutreachCandidate } from '../../../../../packages/api-contracts/src/index.js'; +import { TextAreaField } from '../../components/form-fields.js'; +import { Modal } from '../../components/modal.js'; + +interface EditMessageDialogProps { + candidate: OutreachCandidate; + errorMessage?: string | null; + isPending: boolean; + onCancel(): void; + onSave(message: string): void; +} + +export function EditMessageDialog({ candidate, errorMessage = null, isPending, onCancel, onSave }: EditMessageDialogProps) { + const [message, setMessage] = useState(candidate.preparation?.message ?? ''); + const trimmed = message.trim(); + const validationError = trimmed.length === 0 + ? 'Message cannot be empty.' + : trimmed.length > 2000 ? 'Message cannot exceed 2000 characters.' : undefined; + return ( + + Prepared outreach +

Edit message for u/{candidate.author}

+

The saved text will replace the generated opener and be marked as operator-edited for attribution.

+ + {message.length}/2000 + {errorMessage ?

{errorMessage}

: null} +
+ + +
+
+ ); +} diff --git a/apps/dashboard/src/features/outreach/outreach-api.ts b/apps/dashboard/src/features/outreach/outreach-api.ts index d36e434..2e1b889 100644 --- a/apps/dashboard/src/features/outreach/outreach-api.ts +++ b/apps/dashboard/src/features/outreach/outreach-api.ts @@ -1,6 +1,8 @@ import { OutreachClearResponseSchema, + OutreachCandidateDeleteResponseSchema, OutreachFunnelResponseSchema, + OutreachMessageUpdateResponseSchema, } from '../../../../../packages/api-contracts/src/index.js'; import { createDashboardRequest } from '../../api/request.js'; @@ -31,6 +33,30 @@ export function createOutreachApi(fetchImplementation?: typeof fetch) { } return OutreachClearResponseSchema.parse(payload); }, + async updateMessage(postId: string, message: string) { + const response = await request(`/api/outreach/${encodeURIComponent(postId)}/message`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message }), + }); + const payload: unknown = await response.json(); + if (!response.ok) { + const detail = typeof payload === 'object' && payload !== null && 'error' in payload + && typeof payload.error === 'string' ? payload.error : `Could not edit message (${response.status})`; + throw new Error(detail); + } + return OutreachMessageUpdateResponseSchema.parse(payload); + }, + async deleteCandidate(postId: string) { + const response = await request(`/api/outreach/${encodeURIComponent(postId)}`, { method: 'DELETE' }); + const payload: unknown = await response.json(); + if (!response.ok) { + const detail = typeof payload === 'object' && payload !== null && 'error' in payload + && typeof payload.error === 'string' ? payload.error : `Could not remove candidate (${response.status})`; + throw new Error(detail); + } + return OutreachCandidateDeleteResponseSchema.parse(payload); + }, }; } diff --git a/apps/dashboard/src/features/outreach/outreach-page.tsx b/apps/dashboard/src/features/outreach/outreach-page.tsx index 1abd6b4..21d32a0 100644 --- a/apps/dashboard/src/features/outreach/outreach-page.tsx +++ b/apps/dashboard/src/features/outreach/outreach-page.tsx @@ -7,6 +7,8 @@ import { RedditUsernameLink } from '../../components/reddit-username-link.js'; import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js'; import { outreachApi } from './outreach-api.js'; import { ClearFunnelDialog } from './clear-funnel-dialog.js'; +import { DeleteCandidateDialog } from './delete-candidate-dialog.js'; +import { EditMessageDialog } from './edit-message-dialog.js'; import './outreach.css'; const STAGES: ReadonlyArray<{ @@ -43,7 +45,9 @@ function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate const preparation = candidate.preparation; if (!preparation) return null; const targeting = preparation.physiqueTargeting; - const strategy = preparation.scripted + const strategy = preparation.operatorEdited + ? 'Operator edited' + : preparation.scripted ? 'Scripted opener' : preparation.strategyId ?? 'Default strategy'; @@ -85,7 +89,17 @@ function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate ); } -function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: number }) { +function CandidateCard({ + candidate, + now, + onDelete, + onEdit, +}: { + candidate: OutreachCandidate; + now: number; + onDelete(candidate: OutreachCandidate): void; + onEdit(candidate: OutreachCandidate): void; +}) { const waitingUntil = candidate.availableAt > now; const isKnownStatus = KNOWN_STATUSES.has(candidate.status); return ( @@ -113,6 +127,14 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now: {candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'} {waitingUntil ? Retry {formatWaitTime(candidate.availableAt, now)} : null} +
+ {candidate.preparation && (candidate.status === 'ready' || candidate.status === 'retryable') ? ( + + ) : null} + {candidate.status !== 'sending' ? ( + + ) : null} +
{candidate.note ?

Latest note{candidate.note}

: null} {candidate.owner ? : null} @@ -132,7 +154,18 @@ export function OutreachPage() { mutationFn: outreachApi.clearFunnel, onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }), }); + const editMutation = useMutation({ + mutationFn: ({ postId, message }: { postId: string; message: string }) => outreachApi.updateMessage(postId, message), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }), + }); + const deleteMutation = useMutation({ + mutationFn: (postId: string) => outreachApi.deleteCandidate(postId), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }), + }); const [isConfirmingClear, setIsConfirmingClear] = useState(false); + const [editingCandidate, setEditingCandidate] = useState(null); + const [deletingCandidate, setDeletingCandidate] = useState(null); + const [actionNotice, setActionNotice] = useState(null); // Single client-side clock for every displayed offset: retry countdowns tick // every second while visible, everything else every 10 seconds. @@ -173,7 +206,7 @@ export function OutreachPage() {