← 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
M app/api/pulse/petitions/route.tsM app/pulse/petitions/create/page.tsx
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 →