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() {