[object Object]

← back to Rentv 2026

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

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 2026  ·  feat(services): /services — Greater LA CRE services director d7e04f05 →