← back to Rentv
PR-intel: soft-delete (Discard) for outreach drafts
a8efed7c0ca69ed22053b2edec6f8211c2ed6b91 · 2026-08-06 10:47:29 -0700 · Steve Abrams
The outreach layer was append-only — POST /outreach/draft always inserts a
new row and there was no delete/void path, so regenerating a draft for the
same person left un-removable duplicates (Tim Jemal #3/#6/#7) that inflated
drafts_awaiting_review.
- migration 010: add 'discarded' to pr_outreach_status + a discarded_at
column (soft-delete, nothing sent is ever hard-deleted).
- outreach.discard(): status='discarded' + discarded_at; HARD rule — a row
with sent_at IS NOT NULL can never be discarded (audit trail); only
non-sent drafts (draft_generated/needs_review/provider_draft_created/
approved) are discardable; idempotent.
- outreach.list(): hide discarded rows unless ?status=discarded or
?include_discarded=1. drafts_awaiting_review already excludes them (it
only counts draft_generated/needs_review).
- DELETE /api/pr/outreach/:id (adminOnly) wired to outreach.discard.
- review.html Reply-history panel: a Discard button on each non-sent draft,
wired via PR.api('/outreach/'+id, {method:'DELETE'}); sent messages get no
button.
TK-10297. Migration + discard rules verified against throwaway
rentv_pr_test; full test suite 58/58. Live deploy + prod migration
Steve-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/admin/pr-intelligence/review.htmlM src/pr/index.jsA src/pr/migrations/010_outreach_discard.sqlM src/pr/services/outreach.js
Diff
commit a8efed7c0ca69ed22053b2edec6f8211c2ed6b91
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Aug 6 10:47:29 2026 -0700
PR-intel: soft-delete (Discard) for outreach drafts
The outreach layer was append-only — POST /outreach/draft always inserts a
new row and there was no delete/void path, so regenerating a draft for the
same person left un-removable duplicates (Tim Jemal #3/#6/#7) that inflated
drafts_awaiting_review.
- migration 010: add 'discarded' to pr_outreach_status + a discarded_at
column (soft-delete, nothing sent is ever hard-deleted).
- outreach.discard(): status='discarded' + discarded_at; HARD rule — a row
with sent_at IS NOT NULL can never be discarded (audit trail); only
non-sent drafts (draft_generated/needs_review/provider_draft_created/
approved) are discardable; idempotent.
- outreach.list(): hide discarded rows unless ?status=discarded or
?include_discarded=1. drafts_awaiting_review already excludes them (it
only counts draft_generated/needs_review).
- DELETE /api/pr/outreach/:id (adminOnly) wired to outreach.discard.
- review.html Reply-history panel: a Discard button on each non-sent draft,
wired via PR.api('/outreach/'+id, {method:'DELETE'}); sent messages get no
button.
TK-10297. Migration + discard rules verified against throwaway
rentv_pr_test; full test suite 58/58. Live deploy + prod migration
Steve-gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/admin/pr-intelligence/review.html | 13 ++++++++++++-
src/pr/index.js | 4 ++++
src/pr/migrations/010_outreach_discard.sql | 14 ++++++++++++++
src/pr/services/outreach.js | 26 +++++++++++++++++++++++++-
4 files changed, 55 insertions(+), 2 deletions(-)
diff --git a/public/admin/pr-intelligence/review.html b/public/admin/pr-intelligence/review.html
index a2848726..419c7aee 100644
--- a/public/admin/pr-intelligence/review.html
+++ b/public/admin/pr-intelligence/review.html
@@ -157,8 +157,19 @@
const api = isOrg ? '/organizations/' : '/people/';
await PR.guard(() => PR.api(api + o.id, { method: 'PUT', body: { notes: document.getElementById('r-notes').value } }), 'Note saved');
};
+ // Outreach / draft history. Non-sent drafts get a Discard button (soft-delete → status
+ // 'discarded'); sent/replied messages are audit-trail and can never be discarded, so no button.
+ const DISCARDABLE = ['draft_generated', 'needs_review', 'provider_draft_created', 'approved'];
document.getElementById('r-actions-right').innerHTML = `<h3>Reply history</h3>` +
- ((o.outreach || []).map((m) => `<div class="gatechk">${PR.statusBadge(m.status)} <span>${PR.esc(m.subject || '')}</span></div>`).join('') || '<div class="empty">None</div>');
+ ((o.outreach || []).map((m) => `<div class="gatechk" data-mid="${m.id}">${PR.statusBadge(m.status)} <span style="flex:1">${PR.esc(m.subject || '(no subject)')}</span>`
+ + (!m.sent_at && DISCARDABLE.includes(m.status)
+ ? `<button class="btn sm danger" data-discard="${m.id}" title="Soft-delete this draft (nothing sent is ever removed)">Discard</button>` : '')
+ + `</div>`).join('') || '<div class="empty">None</div>');
+ document.querySelectorAll('#r-actions-right [data-discard]').forEach((b) => b.addEventListener('click', async () => {
+ if (!confirm('Discard this draft? It drops out of the review queue. Sent messages can never be discarded.')) return;
+ await PR.guard(() => PR.api('/outreach/' + b.dataset.discard, { method: 'DELETE' }), 'Draft discarded');
+ const row = b.closest('.gatechk'); if (row) row.remove();
+ }));
}
async function act(action) {
diff --git a/src/pr/index.js b/src/pr/index.js
index d157d27d..ccf53938 100644
--- a/src/pr/index.js
+++ b/src/pr/index.js
@@ -549,6 +549,10 @@ module.exports = function mountPR(app, { adminOnly, sendPage }) {
res.json(await outreach.send(Number(req.params.id), { confirm: (req.body || {}).confirm === true, actor: actorOf(req) }))));
app.post('/api/pr/outreach/:id/status', adminOnly, h(async (req, res) =>
res.json(await outreach.transition(Number(req.params.id), (req.body || {}).status, actorOf(req), (req.body || {}).detail))));
+ // Soft-delete (discard) an un-sent draft: status='discarded' + discarded_at. Sent messages
+ // (sent_at IS NOT NULL) can NEVER be deleted — enforced in outreach.discard.
+ app.delete('/api/pr/outreach/:id', adminOnly, h(async (req, res) =>
+ res.json(await outreach.discard(Number(req.params.id), actorOf(req)))));
// ── Inbox & replies ────────────────────────────────────────────────────────
app.get('/api/pr/inbox', adminOnly, h(async (_q, res) => res.json(await replies.inbox({}))));
diff --git a/src/pr/migrations/010_outreach_discard.sql b/src/pr/migrations/010_outreach_discard.sql
new file mode 100644
index 00000000..8cac4632
--- /dev/null
+++ b/src/pr/migrations/010_outreach_discard.sql
@@ -0,0 +1,14 @@
+-- 008_outreach_discard.sql
+-- Soft-delete support for outreach drafts (TK-10297, Change 2).
+--
+-- The outreach layer is append-only: POST /api/pr/outreach/draft always INSERTs a new row,
+-- so re-generating a draft for the same person leaves un-removable duplicates (e.g. Tim Jemal
+-- #3/#6/#7) that inflate drafts_awaiting_review. This migration adds a `discarded` lifecycle
+-- state + a `discarded_at` timestamp so a non-sent draft can be voided (never hard-deleted —
+-- nothing sent is ever lost) and dropped from the review list + the awaiting-review count.
+
+-- Add the `discarded` value to the outreach status enum (idempotent: IF NOT EXISTS).
+ALTER TYPE pr_outreach_status ADD VALUE IF NOT EXISTS 'discarded';
+
+-- When a draft was voided (audit-friendly; NULL for every live/sent row).
+ALTER TABLE pr_outreach_messages ADD COLUMN IF NOT EXISTS discarded_at timestamptz;
diff --git a/src/pr/services/outreach.js b/src/pr/services/outreach.js
index 67be9acf..d4666876 100644
--- a/src/pr/services/outreach.js
+++ b/src/pr/services/outreach.js
@@ -103,6 +103,9 @@ async function list(f = {}) {
const add = (sql, v) => { params.push(v); where.push(sql.replace('?', '$' + params.length)); };
if (f.tenant_id) add('m.tenant_id = ?', f.tenant_id); // multi-tenant isolation
if (f.status) add('m.status = ?::pr_outreach_status', f.status);
+ // Discarded (soft-deleted) drafts are hidden from the review list unless explicitly asked for
+ // (?status=discarded or ?include_discarded=1).
+ else if (!(f.include_discarded === '1' || f.include_discarded === true)) where.push(`m.status <> 'discarded'`);
if (f.campaign_id) add('m.campaign_id = ?', Number(f.campaign_id));
if (f.person_id) add('m.person_id = ?', Number(f.person_id));
if (f.direction) add('m.direction = ?', f.direction);
@@ -135,6 +138,27 @@ async function updateDraft(id, { subject, rendered_text, rendered_html, follow_u
return row;
}
+/**
+ * SOFT-DELETE (discard) a draft. Sets status='discarded' + discarded_at=now() so nothing is
+ * ever hard-deleted. HARD RULE: a message that was ever sent (sent_at IS NOT NULL) can NEVER be
+ * discarded — the audit trail is preserved. Only non-sent, non-terminal drafts are discardable.
+ */
+const DISCARDABLE_STATUSES = ['draft_generated', 'needs_review', 'provider_draft_created', 'approved'];
+async function discard(id, actor) {
+ return db.tx(async (client) => {
+ const m = (await client.query('SELECT t.* FROM pr_outreach_messages t WHERE id=$1 FOR UPDATE', [id])).rows[0];
+ if (!m) throw new Error('not found');
+ if (m.sent_at) throw new Error('cannot discard a message that was already sent (audit trail preserved)');
+ if (m.status === 'discarded') return m; // idempotent
+ if (!DISCARDABLE_STATUSES.includes(m.status)) throw new Error(`only unsent drafts can be discarded (status is ${m.status})`);
+ const row = (await client.query(
+ `UPDATE pr_outreach_messages SET status='discarded'::pr_outreach_status, discarded_at=now() WHERE id=$1 RETURNING *`, [id])).rows[0];
+ await audit.log({ actor, action: 'outreach.discarded', entity_type: 'message', entity_id: id, before: { status: m.status }, after: { status: 'discarded' } }, client);
+ if (m.person_id) await audit.activity({ entity_type: 'person', entity_id: m.person_id, activity: 'draft_discarded', detail: { message_id: id }, actor }, client);
+ return row;
+ });
+}
+
async function transition(id, to, actor, detail) {
return db.tx(async (client) => {
const m = (await client.query('SELECT t.* FROM pr_outreach_messages t WHERE id=$1 FOR UPDATE', [id])).rows[0];
@@ -213,4 +237,4 @@ async function send(id, { confirm, actor }) {
return row;
}
-module.exports = { readiness, generateDraft, get, list, updateDraft, transition, approve, createProviderDraft, send };
+module.exports = { readiness, generateDraft, get, list, updateDraft, discard, transition, approve, createProviderDraft, send };
← 46664637 PR-intel CA gate: count a verified general_press_email as a
·
back to Rentv
·
feat(services): /services — Greater LA CRE services director d7e04f05 →