Add outreach candidate editing controls

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:12:54 +01:00
1 parent c75e71f26e
commit 79c508be2b
19 files changed
+434 -12

No files matched your search

@@ -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 (
<Modal className="dialog-card" isDismissLocked={isPending} labelledBy="delete-candidate-title" onClose={onCancel}>
<span className="eyebrow">Confirm queue action</span>
<h2 id="delete-candidate-title">Remove u/{candidate.author} from the funnel?</h2>
<p>This candidate will be cancelled and retained in the audit history, so the same item will not be rediscovered.</p>
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
<div className="dialog-actions">
<button autoFocus className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Removing…' : 'Remove candidate'}
</button>
</div>
</Modal>
);
}
@@ -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 (
<Modal className="dialog-card outreach-edit-dialog" isDismissLocked={isPending} labelledBy="edit-outreach-title" onClose={onCancel}>
<span className="eyebrow">Prepared outreach</span>
<h2 id="edit-outreach-title">Edit message for u/{candidate.author}</h2>
<p>The saved text will replace the generated opener and be marked as operator-edited for attribution.</p>
<TextAreaField
error={validationError}
label="Message"
name="outreach-message"
onChange={setMessage}
rows={7}
value={message}
/>
<small className="outreach-character-count">{message.length}/2000</small>
{errorMessage ? <p className="dialog-error" role="alert">{errorMessage}</p> : null}
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} type="button">Cancel</button>
<button
className="button primary"
disabled={isPending || validationError !== undefined || trimmed === candidate.preparation?.message}
onClick={() => onSave(trimmed)}
type="button"
>
{isPending ? 'Saving…' : 'Save message'}
</button>
</div>
</Modal>
);
}
@@ -1,6 +1,8 @@
import { import {
OutreachClearResponseSchema, OutreachClearResponseSchema,
OutreachCandidateDeleteResponseSchema,
OutreachFunnelResponseSchema, OutreachFunnelResponseSchema,
OutreachMessageUpdateResponseSchema,
} from '../../../../../packages/api-contracts/src/index.js'; } from '../../../../../packages/api-contracts/src/index.js';
import { createDashboardRequest } from '../../api/request.js'; import { createDashboardRequest } from '../../api/request.js';
@@ -31,6 +33,30 @@ export function createOutreachApi(fetchImplementation?: typeof fetch) {
} }
return OutreachClearResponseSchema.parse(payload); 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);
},
}; };
} }
@@ -7,6 +7,8 @@ import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js'; import { formatExactTime, formatShortRelativeTime } from '../conversations/relative-time.js';
import { outreachApi } from './outreach-api.js'; import { outreachApi } from './outreach-api.js';
import { ClearFunnelDialog } from './clear-funnel-dialog.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'; import './outreach.css';
const STAGES: ReadonlyArray<{ const STAGES: ReadonlyArray<{
@@ -43,7 +45,9 @@ function PreparedCandidateDetails({ candidate }: { candidate: OutreachCandidate
const preparation = candidate.preparation; const preparation = candidate.preparation;
if (!preparation) return null; if (!preparation) return null;
const targeting = preparation.physiqueTargeting; const targeting = preparation.physiqueTargeting;
const strategy = preparation.scripted const strategy = preparation.operatorEdited
? 'Operator edited'
: preparation.scripted
? 'Scripted opener' ? 'Scripted opener'
: preparation.strategyId ?? 'Default strategy'; : 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 waitingUntil = candidate.availableAt > now;
const isKnownStatus = KNOWN_STATUSES.has(candidate.status); const isKnownStatus = KNOWN_STATUSES.has(candidate.status);
return ( return (
@@ -113,6 +127,14 @@ function CandidateCard({ candidate, now }: { candidate: OutreachCandidate; now:
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span> <span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null} {waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
</div> </div>
<div className="candidate-actions">
{candidate.preparation && (candidate.status === 'ready' || candidate.status === 'retryable') ? (
<button className="button secondary" onClick={() => onEdit(candidate)} type="button">Edit message</button>
) : null}
{candidate.status !== 'sending' ? (
<button className="button danger" onClick={() => onDelete(candidate)} type="button">Remove</button>
) : null}
</div>
{candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null} {candidate.note ? <p className="candidate-note"><strong>Latest note</strong>{candidate.note}</p> : null}
{candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null} {candidate.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
<PreparedCandidateDetails candidate={candidate} /> <PreparedCandidateDetails candidate={candidate} />
@@ -132,7 +154,18 @@ export function OutreachPage() {
mutationFn: outreachApi.clearFunnel, mutationFn: outreachApi.clearFunnel,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['outreach', 'funnel'] }), 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 [isConfirmingClear, setIsConfirmingClear] = useState(false);
const [editingCandidate, setEditingCandidate] = useState<OutreachCandidate | null>(null);
const [deletingCandidate, setDeletingCandidate] = useState<OutreachCandidate | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
// Single client-side clock for every displayed offset: retry countdowns tick // Single client-side clock for every displayed offset: retry countdowns tick
// every second while visible, everything else every 10 seconds. // every second while visible, everything else every 10 seconds.
@@ -173,7 +206,7 @@ export function OutreachPage() {
<button <button
className="button danger" className="button danger"
disabled={clearableCount === 0 || clearMutation.isPending} disabled={clearableCount === 0 || clearMutation.isPending}
onClick={() => { clearMutation.reset(); setIsConfirmingClear(true); }} onClick={() => { clearMutation.reset(); setActionNotice(null); setIsConfirmingClear(true); }}
type="button" type="button"
> >
Clear funnel Clear funnel
@@ -198,6 +231,7 @@ export function OutreachPage() {
: ''} : ''}
</div> </div>
) : null} ) : null}
{actionNotice ? <div className="outreach-clear-notice" role="status">{actionNotice}</div> : null}
<section aria-labelledby="active-funnel-heading" className="outreach-runway"> <section aria-labelledby="active-funnel-heading" className="outreach-runway">
<div className="runway-heading"> <div className="runway-heading">
@@ -228,7 +262,15 @@ export function OutreachPage() {
<div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div> <div className="empty-state panel"><strong>The active queue is clear.</strong><span>New potential posts will appear here as discovery finds them.</span></div>
) : ( ) : (
<ol aria-label="Active outreach candidates" className="outreach-candidates"> <ol aria-label="Active outreach candidates" className="outreach-candidates">
{data.candidates.map((candidate) => <CandidateCard candidate={candidate} key={candidate.postId} now={now} />)} {data.candidates.map((candidate) => (
<CandidateCard
candidate={candidate}
key={candidate.postId}
now={now}
onDelete={(selected) => { clearMutation.reset(); deleteMutation.reset(); setActionNotice(null); setDeletingCandidate(selected); }}
onEdit={(selected) => { clearMutation.reset(); editMutation.reset(); setActionNotice(null); setEditingCandidate(selected); }}
/>
))}
</ol> </ol>
)} )}
</section> </section>
@@ -242,6 +284,30 @@ export function OutreachPage() {
sendingCount={data.stages.sending} sendingCount={data.stages.sending}
/> />
) : null} ) : null}
{editingCandidate ? (
<EditMessageDialog
candidate={editingCandidate}
errorMessage={editMutation.isError ? editMutation.error.message : null}
isPending={editMutation.isPending}
onCancel={() => { setEditingCandidate(null); editMutation.reset(); }}
onSave={(message) => editMutation.mutate(
{ postId: editingCandidate.postId, message },
{ onSuccess: () => { setActionNotice(`Message for u/${editingCandidate.author} updated.`); setEditingCandidate(null); } },
)}
/>
) : null}
{deletingCandidate ? (
<DeleteCandidateDialog
candidate={deletingCandidate}
errorMessage={deleteMutation.isError ? deleteMutation.error.message : null}
isPending={deleteMutation.isPending}
onCancel={() => { setDeletingCandidate(null); deleteMutation.reset(); }}
onConfirm={() => deleteMutation.mutate(
deletingCandidate.postId,
{ onSuccess: () => { setActionNotice(`Removed u/${deletingCandidate.author} from the funnel.`); setDeletingCandidate(null); } },
)}
/>
) : null}
</div> </div>
); );
} }
@@ -30,6 +30,10 @@
.outreach-candidate.is-sending .candidate-status-rail span { background: #c1ef91; } .outreach-candidate.is-sending .candidate-status-rail span { background: #c1ef91; }
.outreach-candidate.is-retryable .candidate-status-rail span { background: var(--warning); } .outreach-candidate.is-retryable .candidate-status-rail span { background: var(--warning); }
.outreach-candidate article { display: grid; gap: .7rem; min-width: 0; padding: .9rem 1rem; } .outreach-candidate article { display: grid; gap: .7rem; min-width: 0; padding: .9rem 1rem; }
.candidate-actions { display: flex; flex-wrap: wrap; gap: .55rem; }
.candidate-actions .button { min-height: 2.2rem; padding: .4rem .7rem; }
dialog.outreach-edit-dialog { width: min(38rem, calc(100% - 2rem)); }
.outreach-character-count { display: block; margin-top: .35rem; color: var(--muted); text-align: right; }
.candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; } .candidate-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: .75rem; }
.candidate-labels { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; color: var(--muted); font-size: .72rem; } .candidate-labels { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; color: var(--muted); font-size: .72rem; }
.candidate-status { padding: .25rem .45rem; color: #dce8e2; background: #2b3832; border-radius: .35rem; font-weight: 800; } .candidate-status { padding: .25rem .45rem; color: #dce8e2; background: #2b3832; border-radius: .35rem; font-weight: 800; }
+2
View File
@@ -109,6 +109,8 @@ an administrator credential.
| `GET` | `/api/attention` | Get operator attention items | read | no | | `GET` | `/api/attention` | Get operator attention items | read | no |
| `GET` | `/api/outreach` | Get active outreach candidates and funnel stages | read | no | | `GET` | `/api/outreach` | Get active outreach candidates and funnel stages | read | no |
| `DELETE` | `/api/outreach` | Cancel every unsent outreach candidate except in-flight sends | admin | yes | | `DELETE` | `/api/outreach` | Cancel every unsent outreach candidate except in-flight sends | admin | yes |
| `PATCH` | `/api/outreach/{postId}/message` | Replace a prepared outreach message before sending | admin | yes |
| `DELETE` | `/api/outreach/{postId}` | Cancel one unsent outreach candidate | admin | yes |
| `GET` | `/api/growth` | Get per-account comment growth state | read | no | | `GET` | `/api/growth` | Get per-account comment growth state | read | no |
| `GET` | `/api/growth/{accountId}` | Get one account's growth settings, communities and comment history | read | no | | `GET` | `/api/growth/{accountId}` | Get one account's growth settings, communities and comment history | read | no |
| `PATCH` | `/api/growth/{accountId}` | Enable, disable or re-pace comment growth for an account | admin | yes | | `PATCH` | `/api/growth/{accountId}` | Enable, disable or re-pace comment growth for an account | admin | yes |
+2
View File
@@ -48,6 +48,8 @@ Matrix cooldowns retain the operation that triggered them. A room-creation or in
The operator can manually clear the unsent funnel from the authenticated dashboard. The repository atomically moves discovered, screening, ready, and retryable candidates to a terminal rejection with the reason `Cleared manually by operator`, releases their leases, and retains the audit rows so those candidates are not rediscovered. A `sending` lease is never changed because its remote Matrix outcome may already be in flight. Clearing does not change bot mode; running discovery may refill the funnel immediately. The operator can manually clear the unsent funnel from the authenticated dashboard. The repository atomically moves discovered, screening, ready, and retryable candidates to a terminal rejection with the reason `Cleared manually by operator`, releases their leases, and retains the audit rows so those candidates are not rediscovered. A `sending` lease is never changed because its remote Matrix outcome may already be in flight. Clearing does not change bot mode; running discovery may refill the funnel immediately.
The same surface supports individual cancellation and prepared-message editing. Individual cancellation uses the same terminal audit transition and refuses `sending` or completed rows. Editing is limited to `ready` and `retryable` candidates with a preparation snapshot; it atomically replaces only the opener, clears generated strategy/experiment selection metadata, labels the model attribution `operator-edited`, and re-runs the deterministic outbound location/meeting safeguard. Both operations return HTTP 409 when a stale dashboard loses a race to dispatch.
Screening, retry promotion, dispatch, and the operator's active-queue view are ordered by the Reddit post creation time, newest first; source priority is only a tie-breaker. The dispatch worker leases the newest ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of: Screening, retry promotion, dispatch, and the operator's active-queue view are ordered by the Reddit post creation time, newest first; source priority is only a tie-breaker. The dispatch worker leases the newest ready candidate for an account, materializes the processed post in that account's repository, applies the account rate limiter, checks dry-run, and atomically claims the recipient globally. `ChatService.startConversationDetailed()` returns one of:
- `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent; - `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent;
+1
View File
@@ -65,6 +65,7 @@ The special legacy-unassigned account exists only to preserve rows created befor
13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally. 13. Browsing decisions are unique per account and thing. Before selecting, the service also checks for a `simulated-upvote` by any other owned account, but account-specific rejections do not unnecessarily reserve an item globally.
14. A browsing selection is persisted as `pending` before any vote request. It becomes `submitted` only after Reddit confirms the authenticated upvote, or `failed` with the safe error reason; unconfirmed selections are never retried. Browsing rows never contribute to adaptive comment-growth promotion. 14. A browsing selection is persisted as `pending` before any vote request. It becomes `submitted` only after Reddit confirms the authenticated upvote, or `failed` with the safe error reason; unconfirmed selections are never retried. Browsing rows never contribute to adaptive comment-growth promotion.
15. Manual outreach clearing is a terminal audited transition, not deletion. It rejects discovered, screening, ready, and retryable candidates, releases their leases, and never changes a `sending` row whose remote outcome may be uncertain. 15. Manual outreach clearing is a terminal audited transition, not deletion. It rejects discovered, screening, ready, and retryable candidates, releases their leases, and never changes a `sending` row whose remote outcome may be uncertain.
16. Individual candidate removal follows the same audited transition. Prepared opener edits are allowed only in ready/retryable states; the transaction updates the queue snapshot and any matching processed-post projection, clears AI experiment attribution, and carries `operatorEdited: true` with model ID `operator-edited` through delivery.
10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval. 10. Recipient account status changes only after a definitive Reddit profile response. Transient lookup failures retain the previous value, and the shared check timestamp prevents duplicate lookups across account runtimes during the four-hour interval.
+3 -1
View File
@@ -36,6 +36,8 @@ 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 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.
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.
## State model ## State model
- Remote state: TanStack Query snapshots parsed by shared Zod schemas. - Remote state: TanStack Query snapshots parsed by shared Zod schemas.
@@ -63,7 +65,7 @@ Rules:
- Duplicate/replayed events must be harmless. - Duplicate/replayed events must be harmless.
- A disconnected stream marks data stale but does not blank useful cached content. - A disconnected stream marks data stale but does not blank useful cached content.
- Invalid event payloads are contained and repaired with a snapshot refresh. - Invalid event payloads are contained and repaired with a snapshot refresh.
- The notification centre retains useful lifecycle history, including successful syncs. Sticky toasts are reserved for inbound replies/media and attention-worthy failures or runtime/account states; they remain visible until the operator dismisses them. - The notification centre and sticky toasts are reserved for inbound replies/media and attention-worthy failures or runtime/account states. Routine sync completions, successful sends, healthy lifecycle changes, and raw logs remain visible in their owning screens instead of creating notifications. Toasts remain visible until the operator dismisses them.
- Raw technical logs belong in Operations, not as general user notifications. - Raw technical logs belong in Operations, not as general user notifications.
## Authentication and privacy ## Authentication and privacy
+1
View File
@@ -22,6 +22,7 @@ The product has five connected goals:
- Remote side effects are fail-closed: a send is recorded only after the chat service confirms it; interrupted reply claims reconcile against Matrix history before retrying. A queued reply refreshes Matrix immediately before sending and becomes an auditable superseded claim if a newer inbound event or terminal state appeared while it waited. - Remote side effects are fail-closed: a send is recorded only after the chat service confirms it; interrupted reply claims reconcile against Matrix history before retrying. A queued reply refreshes Matrix immediately before sending and becomes an auditable superseded claim if a newer inbound event or terminal state appeared while it waited.
- Dry-run prepares and records decisions but never sends. - Dry-run prepares and records decisions but never sends.
- Operators can see funnel stages, prepared opener and scoring context, blurred source images, account state, rate limits, failures, handoffs, and recent activity rather than inferring success from a process-health check. - Operators can see funnel stages, prepared opener and scoring context, blurred source images, account state, rate limits, failures, handoffs, and recent activity rather than inferring success from a process-health check.
- Operators may replace a prepared opener before it is leased for sending. The edited text still passes the deterministic location/meeting safeguard, loses generated strategy/experiment attribution, and is durably labelled `operator-edited` rather than credited to the model.
## Candidate policy ## Candidate policy
+1 -1
View File
@@ -28,7 +28,7 @@ This is an ownership map, not an API dump. Read the owning implementation and te
| `services/strategy-selector.ts` | Weighted explore/exploit selection and experiment metadata | Separate image/chat strategy sections; respect mature response window and sample floor. | | `services/strategy-selector.ts` | Weighted explore/exploit selection and experiment metadata | Separate image/chat strategy sections; respect mature response window and sample floor. |
| `services/recipient-deduplicator.ts` | Global recipient history plus live reservation | Case-insensitive; release failures, commit successes, recover stale claims. | | `services/recipient-deduplicator.ts` | Global recipient history plus live reservation | Case-insensitive; release failures, commit successes, recover stale claims. |
| `utils/rate-limiter.ts` | Per-account send spacing/hour limits/jitter | Increment only for confirmed sends; reply priority may use the same limiter. | | `utils/rate-limiter.ts` | Per-account send spacing/hour limits/jitter | Increment only for confirmed sends; reply priority may use the same limiter. |
| `database/candidate-queue-repository.ts` | Durable candidate state machine, leases, retries, counts, and manual clearing | Mutations require the expected worker lease; recover expired/interrupted work; manual clearing preserves sending leases and terminal audit rows. | | `database/candidate-queue-repository.ts` | Durable candidate state machine, leases, retries, prepared-message replacement, counts, and manual clearing | Mutations require an eligible state or expected worker lease; recover expired/interrupted work; operator edits clear AI attribution; manual removals preserve sending leases and terminal audit rows. |
## Account growth ## Account growth
+19
View File
@@ -36,6 +36,7 @@ export const OutreachPreparationSchema = z.strictObject({
modelId: z.string(), modelId: z.string(),
toneId: z.string(), toneId: z.string(),
scripted: z.boolean(), scripted: z.boolean(),
operatorEdited: z.boolean().default(false),
}); });
export const OutreachCandidateSchema = z.strictObject({ export const OutreachCandidateSchema = z.strictObject({
@@ -83,7 +84,25 @@ export const OutreachClearResponseSchema = z.strictObject({
retainedSending: z.number().int().nonnegative(), retainedSending: z.number().int().nonnegative(),
}); });
export const OutreachMessageUpdateRequestSchema = z.strictObject({
message: z.string().trim().min(1).max(2000),
});
export const OutreachMessageUpdateResponseSchema = z.strictObject({
success: z.literal(true),
postId: z.string(),
message: z.string(),
});
export const OutreachCandidateDeleteResponseSchema = z.strictObject({
success: z.literal(true),
postId: z.string(),
});
export type OutreachCandidateStatus = z.infer<typeof OutreachCandidateStatusSchema>; export type OutreachCandidateStatus = z.infer<typeof OutreachCandidateStatusSchema>;
export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>; export type OutreachCandidate = z.infer<typeof OutreachCandidateSchema>;
export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>; export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>;
export type OutreachClearResponse = z.infer<typeof OutreachClearResponseSchema>; export type OutreachClearResponse = z.infer<typeof OutreachClearResponseSchema>;
export type OutreachMessageUpdateRequest = z.infer<typeof OutreachMessageUpdateRequestSchema>;
export type OutreachMessageUpdateResponse = z.infer<typeof OutreachMessageUpdateResponseSchema>;
export type OutreachCandidateDeleteResponse = z.infer<typeof OutreachCandidateDeleteResponseSchema>;
@@ -64,6 +64,9 @@ export interface CandidateQueueClearResult {
retainedSending: number; retainedSending: number;
} }
export type CandidateEditResult = 'updated' | 'not-found' | 'not-editable';
export type CandidateCancelResult = 'cancelled' | 'not-found' | 'not-cancellable';
const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable']; const ACTIVE_STATUSES: CandidateStatus[] = ['discovered', 'screening', 'ready', 'sending', 'retryable'];
/** Global, durable funnel from Reddit listing discovery through outbound delivery. */ /** Global, durable funnel from Reddit listing discovery through outbound delivery. */
@@ -288,6 +291,50 @@ export class CandidateQueueRepository {
})(); })();
} }
updatePreparedMessage(postId: string, message: string, now: number = Date.now()): CandidateEditResult {
return this.db.transaction(() => {
const row = this.db.prepare(`SELECT status, prepared_json FROM candidate_queue WHERE post_id = ?`)
.get(postId) as { status: CandidateStatus; prepared_json: string | null } | undefined;
if (!row) return 'not-found';
if (!['ready', 'retryable'].includes(row.status) || !row.prepared_json) return 'not-editable';
const prepared = JSON.parse(row.prepared_json) as ProcessedPost;
const edited: ProcessedPost = {
...prepared,
generatedMessage: message,
strategyId: null,
experimentId: null,
promptVersion: null,
selectionReason: null,
modelId: 'operator-edited',
operatorEdited: true,
};
const changed = this.db.prepare(`
UPDATE candidate_queue SET prepared_json = ?, updated_at = ?
WHERE post_id = ? AND status IN ('ready', 'retryable')
`).run(JSON.stringify(edited), now, postId).changes;
if (changed === 1) {
this.db.prepare(`UPDATE processed_posts SET generated_message = ? WHERE id = ?`).run(message, postId);
}
return changed === 1 ? 'updated' : 'not-editable';
})();
}
cancelUnsent(postId: string, now: number = Date.now()): CandidateCancelResult {
return this.db.transaction(() => {
const row = this.db.prepare(`SELECT status FROM candidate_queue WHERE post_id = ?`)
.get(postId) as { status: CandidateStatus } | undefined;
if (!row) return 'not-found';
if (!['discovered', 'screening', 'ready', 'retryable'].includes(row.status)) return 'not-cancellable';
const changed = this.db.prepare(`
UPDATE candidate_queue
SET status = 'rejected', last_error = 'Removed manually by operator', updated_at = ?,
lease_owner = NULL, lease_expires_at = NULL, assigned_account_id = NULL
WHERE post_id = ? AND status IN ('discovered', 'screening', 'ready', 'retryable')
`).run(now, postId).changes;
return changed === 1 ? 'cancelled' : 'not-cancellable';
})();
}
/** Re-evaluates prepared image candidates that have not yet been leased for sending. */ /** Re-evaluates prepared image candidates that have not yet been leased for sending. */
reconcilePhysiqueTargeting( reconcilePhysiqueTargeting(
targeting: PhysiqueTargeting, targeting: PhysiqueTargeting,
+2
View File
@@ -25,6 +25,8 @@ export interface ProcessedPost {
sourceType: 'image' | 'chat'; sourceType: 'image' | 'chat';
toneId: ToneKey; toneId: ToneKey;
scripted: boolean; scripted: boolean;
/** True when an operator replaced the generated opener before dispatch. */
operatorEdited?: boolean;
} }
export interface SkipResult { export interface SkipResult {
+9
View File
@@ -193,6 +193,15 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
retainedSending: z.number().int().nonnegative(), retainedSending: z.number().int().nonnegative(),
}), }),
}), }),
operation('editOutreachMessage', 'PATCH', '/api/outreach/{postId}/message', 'Replace a prepared outreach message before sending', ['Operations'], {
access: 'admin',
requestSchema: z.strictObject({ message: z.string().trim().min(1).max(2000) }),
responseSchema: z.strictObject({ success: z.literal(true), postId: z.string(), message: z.string() }),
}),
operation('deleteOutreachCandidate', 'DELETE', '/api/outreach/{postId}', 'Cancel one unsent outreach candidate', ['Operations'], {
access: 'admin',
responseSchema: z.strictObject({ success: z.literal(true), postId: z.string() }),
}),
operation('getGrowthOverview', 'GET', '/api/growth', 'Get per-account comment growth state', ['Growth'], { operation('getGrowthOverview', 'GET', '/api/growth', 'Get per-account comment growth state', ['Growth'], {
responseSchema: GrowthOverviewResponseSchema, responseSchema: GrowthOverviewResponseSchema,
}), }),
+37
View File
@@ -1,8 +1,11 @@
import { Router } from 'express'; import { Router } from 'express';
import { z } from 'zod';
import type { CandidateStatus } from '../../database/candidate-queue-repository.js'; import type { CandidateStatus } from '../../database/candidate-queue-repository.js';
import { isSafeOutboundMessage } from '../../services/outbound-message-safety.js';
import type { WebContext } from '../context.js'; import type { WebContext } from '../context.js';
const ACTIVE_STATUSES = ['discovered', 'screening', 'ready', 'sending', 'retryable'] as const; const ACTIVE_STATUSES = ['discovered', 'screening', 'ready', 'sending', 'retryable'] as const;
const MessageUpdateSchema = z.strictObject({ message: z.string().trim().min(1).max(2000) });
type ActiveCandidateStatus = Exclude<CandidateStatus, 'sent' | 'rejected'>; type ActiveCandidateStatus = Exclude<CandidateStatus, 'sent' | 'rejected'>;
interface OutreachCandidateView { interface OutreachCandidateView {
@@ -96,6 +99,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
modelId: candidate.prepared.modelId, modelId: candidate.prepared.modelId,
toneId: candidate.prepared.toneId, toneId: candidate.prepared.toneId,
scripted: candidate.prepared.scripted, scripted: candidate.prepared.scripted,
operatorEdited: candidate.prepared.operatorEdited ?? false,
} : null, } : null,
note: candidate.lastError, note: candidate.lastError,
owner: candidate.assignedAccountId owner: candidate.assignedAccountId
@@ -148,5 +152,38 @@ export function createOutreachRouter(ctx: WebContext): Router {
}); });
}); });
router.patch('/outreach/:postId/message', (req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) return res.status(503).json({ error: 'Outreach queue not initialized' });
const parsed = MessageUpdateSchema.safeParse(req.body);
if (!parsed.success) return res.status(400).json({ error: 'Message must contain 1–2000 characters' });
if (!isSafeOutboundMessage(parsed.data.message)) {
return res.status(400).json({ error: 'Message fails location or in-person meeting safeguards' });
}
const result = queue.updatePreparedMessage(req.params.postId, parsed.data.message);
if (result === 'not-found') return res.status(404).json({ error: 'Outreach candidate not found' });
if (result === 'not-editable') {
return res.status(409).json({ error: 'Only ready or retrying messages can be edited' });
}
const message = `Edited prepared outreach message for candidate ${req.params.postId}`;
ctx.activity.push('info', message);
ctx.broadcaster.push('info', message);
return res.json({ success: true, postId: req.params.postId, message: parsed.data.message });
});
router.delete('/outreach/:postId', (req, res) => {
const queue = ctx.services.candidateQueue;
if (!queue) return res.status(503).json({ error: 'Outreach queue not initialized' });
const result = queue.cancelUnsent(req.params.postId);
if (result === 'not-found') return res.status(404).json({ error: 'Outreach candidate not found' });
if (result === 'not-cancellable') {
return res.status(409).json({ error: 'Candidate is already sending or has finished' });
}
const message = `Removed outreach candidate ${req.params.postId} from the funnel`;
ctx.activity.push('info', message);
ctx.broadcaster.push('warn', message);
return res.json({ success: true, postId: req.params.postId });
});
return router; return router;
} }
+44
View File
@@ -197,6 +197,50 @@ describe('CandidateQueueRepository', () => {
expect(reasons).toEqual([{ last_error: 'Cleared manually by operator' }]); expect(reasons).toEqual([{ last_error: 'Cleared manually by operator' }]);
}); });
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)!;
queue.markReady(editable.postId, 'screen', prepared(editable.post), 1_002);
db.prepare(`
INSERT INTO processed_posts (account_id, id, subreddit, author, title, generated_message, processed_at)
VALUES ('account-a', ?, 'test', 'alice', 'title', 'prepared message', 1)
`).run(editable.postId);
expect(queue.updatePreparedMessage(editable.postId, 'Operator-written opener', 1_003)).toBe('updated');
expect(queue.listActive().find((candidate) => candidate.postId === editable.postId)?.prepared).toMatchObject({
generatedMessage: 'Operator-written opener',
strategyId: null,
experimentId: null,
promptVersion: null,
selectionReason: null,
modelId: 'operator-edited',
operatorEdited: true,
});
expect((db.prepare(`SELECT generated_message FROM processed_posts WHERE id = ?`).get(editable.postId) as {
generated_message: string;
}).generated_message).toBe('Operator-written opener');
expect(queue.updatePreparedMessage('waiting', 'Nope', 1_004)).toBe('not-editable');
expect(queue.updatePreparedMessage('missing', 'Nope', 1_004)).toBe('not-found');
queue.claimReady('account-a', 'send', 1_000, 1_005);
expect(queue.updatePreparedMessage(editable.postId, 'Too late', 1_006)).toBe('not-editable');
});
it('removes one unsent candidate but refuses to cancel an in-flight send', () => {
queue.enqueue([post('remove', 'alice')], 'image', 1, 1_000);
expect(queue.cancelUnsent('remove', 1_001)).toBe('cancelled');
expect(queue.getStats()).toMatchObject({ byStatus: { rejected: 1 } });
expect(queue.cancelUnsent('remove', 1_002)).toBe('not-cancellable');
expect(queue.cancelUnsent('missing', 1_002)).toBe('not-found');
queue.enqueue([post('sending', 'bob')], 'image', 1, 1_003);
const candidate = queue.claimForScreening('screen', 1_000, 1_004)!;
queue.markReady(candidate.postId, 'screen', prepared(candidate.post), 1_005);
queue.claimReady('account-a', 'send', 1_000, 1_006);
expect(queue.cancelUnsent('sending', 1_007)).toBe('not-cancellable');
expect(queue.listActive()).toEqual([expect.objectContaining({ postId: 'sending', status: 'sending' })]);
});
it('retries a rate-limited prepared candidate and preserves its generated message', () => { it('retries a rate-limited prepared candidate and preserves its generated message', () => {
queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000); queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000);
const screening = queue.claimForScreening('screen', 100, 1_001)!; const screening = queue.claimForScreening('screen', 100, 1_001)!;
+28
View File
@@ -116,6 +116,8 @@ describe('dashboard foundation', () => {
it('shows the live outreach stages and the potential posts being burned down', async () => { it('shows the live outreach stages and the potential posts being burned down', async () => {
installSuccessfulApi(); installSuccessfulApi();
const user = userEvent.setup(); const user = userEvent.setup();
let editedMessage: string | null = null;
let removedCandidate: string | null = null;
server.use( server.use(
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })), http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
http.get('/api/chats/sync', () => HttpResponse.json({ http.get('/api/chats/sync', () => HttpResponse.json({
@@ -148,6 +150,15 @@ describe('dashboard foundation', () => {
note: null, owner: null, note: null, owner: null,
}], }],
})), })),
http.patch('/api/outreach/:postId/message', async ({ params, request }) => {
const body = await request.json() as { message: string };
editedMessage = body.message;
return HttpResponse.json({ success: true, postId: params.postId, message: body.message });
}),
http.delete('/api/outreach/:postId', ({ params }) => {
removedCandidate = String(params.postId);
return HttpResponse.json({ success: true, postId: params.postId });
}),
); );
renderDashboard('/outreach'); renderDashboard('/outreach');
@@ -170,6 +181,23 @@ describe('dashboard foundation', () => {
expect(sourceImage).toHaveClass('is-blurred'); expect(sourceImage).toHaveClass('is-blurred');
await user.click(within(preparation).getByRole('button', { name: 'Reveal source image: [25F] A potential post' })); await user.click(within(preparation).getByRole('button', { name: 'Reveal source image: [25F] A potential post' }));
expect(sourceImage).not.toHaveClass('is-blurred'); expect(sourceImage).not.toHaveClass('is-blurred');
const candidateList = screen.getByRole('list', { name: 'Active outreach candidates' });
await user.click(within(candidateList).getByRole('button', { name: 'Edit message' }));
const editDialog = screen.getByRole('dialog', { name: 'Edit message for u/alice' });
const editor = within(editDialog).getByRole('textbox', { name: 'Message' });
await user.clear(editor);
await user.type(editor, 'A carefully revised opener');
await user.click(within(editDialog).getByRole('button', { name: 'Save message' }));
await waitFor(() => expect(editedMessage).toBe('A carefully revised opener'));
expect(await screen.findByText('Message for u/alice updated.')).toBeInTheDocument();
await user.click(within(candidateList).getByRole('button', { name: 'Remove' }));
const deleteDialog = screen.getByRole('dialog', { name: 'Remove u/alice from the funnel?' });
expect(deleteDialog).toHaveTextContent('retained in the audit history');
await user.click(within(deleteDialog).getByRole('button', { name: 'Remove candidate' }));
await waitFor(() => expect(removedCandidate).toBe('candidate-1'));
expect(await screen.findByText('Removed u/alice from the funnel.')).toBeInTheDocument();
}); });
it('requires confirmation before clearing unsent outreach and preserves in-flight sends', async () => { it('requires confirmation before clearing unsent outreach and preserves in-flight sends', async () => {
+63 -6
View File
@@ -3,7 +3,9 @@ import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
OutreachClearResponseSchema, OutreachClearResponseSchema,
OutreachCandidateDeleteResponseSchema,
OutreachFunnelResponseSchema, OutreachFunnelResponseSchema,
OutreachMessageUpdateResponseSchema,
} from '../../packages/api-contracts/src/index.js'; } from '../../packages/api-contracts/src/index.js';
import type { WebContext } from '../../src/web/context.js'; import type { WebContext } from '../../src/web/context.js';
import { createOutreachRouter } from '../../src/web/routes/outreach.js'; import { createOutreachRouter } from '../../src/web/routes/outreach.js';
@@ -48,13 +50,14 @@ describe('outreach route', () => {
physiqueScore: 6, physiqueScore: 6,
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 }, physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
sourceImageUrl: 'https://i.redd.it/post-1.jpg', sourceImageUrl: 'https://i.redd.it/post-1.jpg',
strategyId: 'direct-observation', strategyId: null,
experimentId: 'strategies:direct-observation:abc123', experimentId: null,
promptVersion: 'abc123', promptVersion: null,
selectionReason: 'exploit', selectionReason: null,
modelId: 'example/image-model', modelId: 'operator-edited',
toneId: 'direct', toneId: 'direct',
scripted: false, scripted: false,
operatorEdited: true,
}, priority: 2, status: 'sending', }, priority: 2, status: 'sending',
discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000, discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000,
leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a', leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a',
@@ -89,7 +92,8 @@ describe('outreach route', () => {
physiqueScore: 6, physiqueScore: 6,
physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 }, physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 },
sourceImageUrl: 'https://i.redd.it/post-1.jpg', sourceImageUrl: 'https://i.redd.it/post-1.jpg',
strategyId: 'direct-observation', strategyId: null,
operatorEdited: true,
}, },
}], }],
}); });
@@ -136,4 +140,57 @@ describe('outreach route', () => {
expect.objectContaining({ total: 7, retainedSending: 1 }), expect.objectContaining({ total: 7, retainedSending: 1 }),
); );
}); });
it('edits a prepared message and removes an individual unsent candidate', async () => {
const updatePreparedMessage = vi.fn(() => 'updated' as const);
const cancelUnsent = vi.fn(() => 'cancelled' as const);
const activity = { push: vi.fn() };
const broadcaster = { push: vi.fn() };
const context = {
services: { candidateQueue: { updatePreparedMessage, cancelUnsent } }, activity, broadcaster,
} as unknown as WebContext;
const app = express().use(express.json()).use('/api', createOutreachRouter(context));
const editResponse = await request(app, '/api/outreach/post%2Fone/message', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: ' A revised opener ' }),
});
expect(OutreachMessageUpdateResponseSchema.parse(await editResponse.json())).toEqual({
success: true, postId: 'post/one', message: 'A revised opener',
});
expect(updatePreparedMessage).toHaveBeenCalledWith('post/one', 'A revised opener');
const deleteResponse = await request(app, '/api/outreach/post%2Fone', { method: 'DELETE' });
expect(OutreachCandidateDeleteResponseSchema.parse(await deleteResponse.json())).toEqual({
success: true, postId: 'post/one',
});
expect(cancelUnsent).toHaveBeenCalledWith('post/one');
expect(activity.push).toHaveBeenCalledTimes(2);
});
it('rejects unsafe edits and stale mutations that reached the sending boundary', async () => {
const updatePreparedMessage = vi.fn(() => 'not-editable' as const);
const cancelUnsent = vi.fn(() => 'not-cancellable' as const);
const context = {
services: { candidateQueue: { updatePreparedMessage, cancelUnsent } },
activity: { push: vi.fn() }, broadcaster: { push: vi.fn() },
} as unknown as WebContext;
const app = express().use(express.json()).use('/api', createOutreachRouter(context));
const unsafe = await request(app, '/api/outreach/p1/message', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'We should meet tonight' }),
});
expect(unsafe.status).toBe(400);
expect(updatePreparedMessage).not.toHaveBeenCalled();
const staleEdit = await request(app, '/api/outreach/p1/message', {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'A safe revised message' }),
});
expect(staleEdit.status).toBe(409);
const staleDelete = await request(app, '/api/outreach/p1', { method: 'DELETE' });
expect(staleDelete.status).toBe(409);
});
}); });