Add outreach candidate editing controls
This commit is contained in:
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 {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
<span>{candidate.attemptCount} {candidate.attemptCount === 1 ? 'attempt' : 'attempts'}</span>
|
||||
{waitingUntil ? <span title={formatExactTime(candidate.availableAt)}>Retry {formatWaitTime(candidate.availableAt, now)}</span> : null}
|
||||
</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.owner ? <AccountOwnerBadge owner={candidate.owner} /> : null}
|
||||
<PreparedCandidateDetails candidate={candidate} />
|
||||
@@ -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<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
|
||||
// every second while visible, everything else every 10 seconds.
|
||||
@@ -173,7 +206,7 @@ export function OutreachPage() {
|
||||
<button
|
||||
className="button danger"
|
||||
disabled={clearableCount === 0 || clearMutation.isPending}
|
||||
onClick={() => { clearMutation.reset(); setIsConfirmingClear(true); }}
|
||||
onClick={() => { clearMutation.reset(); setActionNotice(null); setIsConfirmingClear(true); }}
|
||||
type="button"
|
||||
>
|
||||
Clear funnel
|
||||
@@ -198,6 +231,7 @@ export function OutreachPage() {
|
||||
: ''}
|
||||
</div>
|
||||
) : null}
|
||||
{actionNotice ? <div className="outreach-clear-notice" role="status">{actionNotice}</div> : null}
|
||||
|
||||
<section aria-labelledby="active-funnel-heading" className="outreach-runway">
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
@@ -242,6 +284,30 @@ export function OutreachPage() {
|
||||
sendingCount={data.stages.sending}
|
||||
/>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,10 @@
|
||||
.outreach-candidate.is-sending .candidate-status-rail span { background: #c1ef91; }
|
||||
.outreach-candidate.is-retryable .candidate-status-rail span { background: var(--warning); }
|
||||
.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-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; }
|
||||
|
||||
@@ -109,6 +109,8 @@ an administrator credential.
|
||||
| `GET` | `/api/attention` | Get operator attention items | 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 |
|
||||
| `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/{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 |
|
||||
|
||||
@@ -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 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:
|
||||
|
||||
- `sent`: persist the message and outcome metadata, commit recipient history, update activity, and mark the candidate sent;
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
- Remote state: TanStack Query snapshots parsed by shared Zod schemas.
|
||||
@@ -63,7 +65,7 @@ Rules:
|
||||
- Duplicate/replayed events must be harmless.
|
||||
- A disconnected stream marks data stale but does not blank useful cached content.
|
||||
- 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.
|
||||
|
||||
## Authentication and privacy
|
||||
|
||||
@@ -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.
|
||||
- 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 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
|
||||
|
||||
|
||||
+1
-1
@@ -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/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. |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const OutreachPreparationSchema = z.strictObject({
|
||||
modelId: z.string(),
|
||||
toneId: z.string(),
|
||||
scripted: z.boolean(),
|
||||
operatorEdited: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const OutreachCandidateSchema = z.strictObject({
|
||||
@@ -83,7 +84,25 @@ export const OutreachClearResponseSchema = z.strictObject({
|
||||
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 OutreachCandidate = z.infer<typeof OutreachCandidateSchema>;
|
||||
export type OutreachFunnelResponse = z.infer<typeof OutreachFunnelResponseSchema>;
|
||||
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;
|
||||
}
|
||||
|
||||
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'];
|
||||
|
||||
/** 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. */
|
||||
reconcilePhysiqueTargeting(
|
||||
targeting: PhysiqueTargeting,
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface ProcessedPost {
|
||||
sourceType: 'image' | 'chat';
|
||||
toneId: ToneKey;
|
||||
scripted: boolean;
|
||||
/** True when an operator replaced the generated opener before dispatch. */
|
||||
operatorEdited?: boolean;
|
||||
}
|
||||
|
||||
export interface SkipResult {
|
||||
|
||||
@@ -193,6 +193,15 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
|
||||
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'], {
|
||||
responseSchema: GrowthOverviewResponseSchema,
|
||||
}),
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type { CandidateStatus } from '../../database/candidate-queue-repository.js';
|
||||
import { isSafeOutboundMessage } from '../../services/outbound-message-safety.js';
|
||||
import type { WebContext } from '../context.js';
|
||||
|
||||
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'>;
|
||||
|
||||
interface OutreachCandidateView {
|
||||
@@ -96,6 +99,7 @@ export function createOutreachRouter(ctx: WebContext): Router {
|
||||
modelId: candidate.prepared.modelId,
|
||||
toneId: candidate.prepared.toneId,
|
||||
scripted: candidate.prepared.scripted,
|
||||
operatorEdited: candidate.prepared.operatorEdited ?? false,
|
||||
} : null,
|
||||
note: candidate.lastError,
|
||||
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;
|
||||
}
|
||||
@@ -197,6 +197,50 @@ describe('CandidateQueueRepository', () => {
|
||||
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', () => {
|
||||
queue.enqueue([post('p1', 'alice')], 'image', 1, 1_000);
|
||||
const screening = queue.claimForScreening('screen', 100, 1_001)!;
|
||||
|
||||
@@ -116,6 +116,8 @@ describe('dashboard foundation', () => {
|
||||
it('shows the live outreach stages and the potential posts being burned down', async () => {
|
||||
installSuccessfulApi();
|
||||
const user = userEvent.setup();
|
||||
let editedMessage: string | null = null;
|
||||
let removedCandidate: string | null = null;
|
||||
server.use(
|
||||
http.get('/api/login/status', () => HttpResponse.json({ ready: true, loggedIn: true, status: 'authenticated' })),
|
||||
http.get('/api/chats/sync', () => HttpResponse.json({
|
||||
@@ -148,6 +150,15 @@ describe('dashboard foundation', () => {
|
||||
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');
|
||||
@@ -170,6 +181,23 @@ describe('dashboard foundation', () => {
|
||||
expect(sourceImage).toHaveClass('is-blurred');
|
||||
await user.click(within(preparation).getByRole('button', { name: 'Reveal source image: [25F] A potential post' }));
|
||||
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 () => {
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { AddressInfo } from 'node:net';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
OutreachClearResponseSchema,
|
||||
OutreachCandidateDeleteResponseSchema,
|
||||
OutreachFunnelResponseSchema,
|
||||
OutreachMessageUpdateResponseSchema,
|
||||
} from '../../packages/api-contracts/src/index.js';
|
||||
import type { WebContext } from '../../src/web/context.js';
|
||||
import { createOutreachRouter } from '../../src/web/routes/outreach.js';
|
||||
@@ -48,13 +50,14 @@ describe('outreach route', () => {
|
||||
physiqueScore: 6,
|
||||
physiqueTargeting: { enabled: true, minScore: 4, maxScore: 7 },
|
||||
sourceImageUrl: 'https://i.redd.it/post-1.jpg',
|
||||
strategyId: 'direct-observation',
|
||||
experimentId: 'strategies:direct-observation:abc123',
|
||||
promptVersion: 'abc123',
|
||||
selectionReason: 'exploit',
|
||||
modelId: 'example/image-model',
|
||||
strategyId: null,
|
||||
experimentId: null,
|
||||
promptVersion: null,
|
||||
selectionReason: null,
|
||||
modelId: 'operator-edited',
|
||||
toneId: 'direct',
|
||||
scripted: false,
|
||||
operatorEdited: true,
|
||||
}, priority: 2, status: 'sending',
|
||||
discoveredAt: 1_000, updatedAt: 2_000, availableAt: 2_000,
|
||||
leaseOwner: 'worker-secret', leaseExpiresAt: 3_000, assignedAccountId: 'account-a',
|
||||
@@ -89,7 +92,8 @@ describe('outreach route', () => {
|
||||
physiqueScore: 6,
|
||||
physiqueTargeting: { enabled: true, minScore: 1, maxScore: 6 },
|
||||
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 }),
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user