[object Object]

← back to Norma

petition create: debounce double-submit via useRef guard + 30-min server-side merge window

9619a2a1ba5df96def106258185bb25a9554e89f · 2026-05-20 09:08:10 -0700 · Steve Abrams

Two-layer fix for the create-page double-submit bug that produced empty
stub + real-content dupe rows (SAVE Plan drafts incident, 2026-05-20).

Client (create/page.tsx): useRef inFlight guard runs synchronously inside
handleSubmit so React strict-mode double-fire and rapid clicks can't sneak
past the state-based 'submitting' gate.

Server (api/pulse/petitions/route.ts): before INSERT, look for a same-title
active petition created in the last 30 minutes. If found, MERGE the new
submission's missing fields into it (body/description/target/category/tags/
goal — only fills empties) and return the existing row with idempotent:true.
Catches rapid double-clicks (no-op merge) AND the publish-empty-then-publish-
filled pattern (stub gets filled in).

Smoke-tested locally: 3 submits with same title -> 1 row, all idempotent.

Files touched

Diff

commit 9619a2a1ba5df96def106258185bb25a9554e89f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 20 09:08:10 2026 -0700

    petition create: debounce double-submit via useRef guard + 30-min server-side merge window
    
    Two-layer fix for the create-page double-submit bug that produced empty
    stub + real-content dupe rows (SAVE Plan drafts incident, 2026-05-20).
    
    Client (create/page.tsx): useRef inFlight guard runs synchronously inside
    handleSubmit so React strict-mode double-fire and rapid clicks can't sneak
    past the state-based 'submitting' gate.
    
    Server (api/pulse/petitions/route.ts): before INSERT, look for a same-title
    active petition created in the last 30 minutes. If found, MERGE the new
    submission's missing fields into it (body/description/target/category/tags/
    goal — only fills empties) and return the existing row with idempotent:true.
    Catches rapid double-clicks (no-op merge) AND the publish-empty-then-publish-
    filled pattern (stub gets filled in).
    
    Smoke-tested locally: 3 submits with same title -> 1 row, all idempotent.
---
 app/api/pulse/petitions/route.ts    | 42 ++++++++++++++++++++++++++++++++++++-
 app/pulse/petitions/create/page.tsx |  8 +++++--
 2 files changed, 47 insertions(+), 3 deletions(-)

diff --git a/app/api/pulse/petitions/route.ts b/app/api/pulse/petitions/route.ts
index ab00157..b6298d9 100644
--- a/app/api/pulse/petitions/route.ts
+++ b/app/api/pulse/petitions/route.ts
@@ -153,9 +153,49 @@ export async function POST(request: NextRequest) {
       }
     }
 
+    const trimmedTitle = body.title.trim();
     const petitionBody = (body.body || '').trim() || null;
     const target = (body.target || '').trim() || null;
 
+    // Dedup window: if a same-title active petition was created in the last
+    // 30 minutes, treat this as the same request and MERGE missing fields
+    // rather than inserting a duplicate row. Catches both rapid double-clicks
+    // (rows identical, no-op merge) and the "publish-empty then publish-filled"
+    // pattern (stub gets filled in by the second submission).
+    const recent = await query(
+      `SELECT id, title, body, description, target, category, tags,
+              signature_count, signature_goal, is_featured, created_at
+       FROM petitions
+       WHERE LOWER(title) = LOWER($1)
+         AND status = 'active'
+         AND created_at > NOW() - INTERVAL '30 minutes'
+       ORDER BY created_at DESC
+       LIMIT 1`,
+      [trimmedTitle]
+    );
+
+    if (recent.rows.length > 0) {
+      const existingId = recent.rows[0].id;
+      const merged = await query(
+        `UPDATE petitions
+            SET body        = COALESCE(NULLIF(body, ''), $2),
+                description = COALESCE(NULLIF(description, ''), $2),
+                target      = COALESCE(NULLIF(target, ''), $3),
+                category    = CASE WHEN category = 'other' THEN $4 ELSE category END,
+                tags        = CASE WHEN COALESCE(array_length(tags, 1), 0) = 0 THEN $5 ELSE tags END,
+                signature_goal = COALESCE(signature_goal, $6),
+                updated_at  = NOW()
+          WHERE id = $1
+          RETURNING id, title, body, description, target, category, tags,
+                    signature_count, signature_goal, is_featured, created_at`,
+        [existingId, petitionBody, target, category, tags, signatureGoal]
+      );
+      return NextResponse.json(
+        { petition: merged.rows[0], idempotent: true },
+        { status: 200 }
+      );
+    }
+
     const result = await query(
       `INSERT INTO petitions
          (title, url, platform, description, body, target, category, tags,
@@ -164,7 +204,7 @@ export async function POST(request: NextRequest) {
        RETURNING id, title, body, description, target, category, tags,
                  signature_count, signature_goal, is_featured, created_at`,
       [
-        body.title.trim(),
+        trimmedTitle,
         petitionBody,
         target,
         category,
diff --git a/app/pulse/petitions/create/page.tsx b/app/pulse/petitions/create/page.tsx
index baee06b..b77b7a4 100644
--- a/app/pulse/petitions/create/page.tsx
+++ b/app/pulse/petitions/create/page.tsx
@@ -1,6 +1,6 @@
 'use client';
 
-import React, { useState } from 'react';
+import React, { useRef, useState } from 'react';
 import Link from 'next/link';
 import { useRouter } from 'next/navigation';
 import {
@@ -61,11 +61,14 @@ export default function CreatePetitionPage() {
   const [error, setError] = useState<string | null>(null);
   const [success, setSuccess] = useState<{ id: string; title: string } | null>(null);
 
+  const inFlight = useRef(false);
+
   const canSubmit = title.trim().length >= 5 && !submitting;
 
   async function handleSubmit(e: React.FormEvent) {
     e.preventDefault();
-    if (!canSubmit) return;
+    if (inFlight.current || !canSubmit) return;
+    inFlight.current = true;
 
     setSubmitting(true);
     setError(null);
@@ -96,6 +99,7 @@ export default function CreatePetitionPage() {
       setError('Network error. Please check your connection and try again.');
     } finally {
       setSubmitting(false);
+      inFlight.current = false;
     }
   }
 

← eb21d0d fix(api): cast org_id param as ::uuid not ::text in pipeline  ·  back to Norma  ·  fix(pipeline/reorder): cast org_id param as ::uuid not ::tex 4f1d74b →