fixes: image-url proxy + raw-publish fallback on rewrite failure

1. Clean image URLs
   Article HTML was leaking the full Supabase Storage URL:
     https://supabase.onsethost.com/storage/v1/object/public/distribute-media/<uuid>/<file>
   Now rewritten on render to:
     /img/distribute-media/<uuid>/<file>
   Served by new /img/[...path] route handler that proxies to Supabase
   with 24h public cache + 7d SWR. Better SEO (first-party images) and
   stops leaking the storage host into article markup.

2. Stuck PR submissions
   When the rewrite pipeline fails (Ollama 503 / bad model output), the
   submission used to sit forever in ai_original_submissions without
   becoming a public wp_imported_posts row. PR firms got "delivered"
   status with no visible article anywhere.

   /api/ai/submit now calls publishRawFallback() if rewriteSubmission
   returns anything other than 'succeeded'. The fallback inserts a
   wp_imported_posts row containing the raw HTML body with a visible
   "Distributed as a press release — staff editorial rewrite pending"
   banner at the top, so the article surfaces immediately. A future
   retry job can swap in the rewritten content when Ollama recovers.
This commit is contained in:
Ryan Salazar
2026-05-27 19:43:35 +00:00
parent 6e3a93f90d
commit 61910d6c8f
4 changed files with 136 additions and 2 deletions

View File

@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { ingestRawSubmission, rewriteSubmission } from "@/lib/ai/rewrite-pipeline";
import { findViolations } from "@/lib/disallowed-phrases-guard";
import { publishRawFallback } from "@/lib/ai/publish-raw-fallback";
export const runtime = "nodejs";
export const maxDuration = 300;
@@ -117,7 +118,24 @@ export async function POST(req: NextRequest) {
const submissionId = await ingestRawSubmission(payload);
const outcome = await rewriteSubmission({ submissionId, personaSlug: payload.personaSlug });
return NextResponse.json({ ok: true, submissionId, outcome });
// If the rewrite failed (Ollama 503 / model output bad), publish the raw
// press release with a "rewrite pending" notice so the submission is at
// least visible. Without this, PR-firm submissions disappear when Ollama
// is saturated.
let fallback: { wp_id: number; wp_slug: string } | null = null;
if (outcome.status !== "succeeded") {
const meta: any = payload.metadata || {};
fallback = await publishRawFallback({
submissionId,
rawTitle: payload.rawTitle,
rawHtml: payload.rawHtml || null,
rawContent: payload.rawContent,
contributorName: payload.contributorName || null,
featuredImageUrl: typeof meta.featured_image_url === "string" ? meta.featured_image_url : null,
});
}
return NextResponse.json({ ok: true, submissionId, outcome, raw_fallback: fallback });
} catch (err: any) {
return NextResponse.json({ error: err.message || String(err) }, { status: 500 });
}

View File

@@ -0,0 +1,44 @@
import { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-static";
/**
* Image proxy: serves files from Supabase Storage under a clean broadcastbeat.com
* URL so article HTML doesn't ship the long `https://supabase.onsethost.com/
* storage/v1/object/public/...` path. Better for SEO + makes images look
* first-party.
*
* Usage: /img/distribute-media/<uuid>/<file>.jpg
* /img/campaign-creatives/<uuid>/<file>.jpg
* /img/bb-forum-avatars/<file>.png
*
* Path segments are forwarded directly to the matching public bucket.
*/
const SUPABASE_BASE = (process.env.NEXT_PUBLIC_SUPABASE_URL || "https://supabase.onsethost.com")
.replace(/\/+$/, "") + "/storage/v1/object/public";
export async function GET(_req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
if (!path || path.length < 2) return new Response("bad path", { status: 400 });
const target = `${SUPABASE_BASE}/${path.map(encodeURIComponent).join("/")}`;
let upstream: Response;
try {
upstream = await fetch(target, { cache: "force-cache" });
} catch (e: any) {
return new Response("upstream error: " + (e?.message || String(e)), { status: 502 });
}
if (!upstream.ok) {
return new Response("not found", { status: upstream.status === 404 ? 404 : 502 });
}
return new Response(upstream.body, {
status: 200,
headers: {
"content-type": upstream.headers.get("content-type") || "image/jpeg",
"cache-control": "public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800",
"x-bb-img-source": "supabase-storage",
},
});
}

View File

@@ -0,0 +1,64 @@
/**
* Fallback: when the Ollama rewrite pipeline fails, publish the raw press
* release directly into wp_imported_posts so the article still surfaces on
* the public site. Includes a visible "rewrite pending" banner so editors
* (and savvy readers) can tell it hasn't been editorialized yet.
*
* Used by /api/ai/submit after rewriteSubmission returns a failed outcome.
*/
import { createAdminClient } from "../supabase/admin";
export async function publishRawFallback(opts: {
submissionId: string;
rawTitle: string;
rawHtml?: string | null;
rawContent: string;
contributorName?: string | null;
featuredImageUrl?: string | null;
}): Promise<{ wp_id: number; wp_slug: string } | null> {
try {
const sb = createAdminClient();
// Slug from title (lower-cased, dashes only, capped) — plus the submission
// id tail so concurrent same-title submissions don't collide.
const baseSlug = (opts.rawTitle || "submission")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
const tail = opts.submissionId.replace(/-/g, "").slice(0, 6);
const wp_slug = `${baseSlug}-pr-${tail}`;
const NOTICE = `<aside style="background:#0d1726;border:1px solid #1e3552;padding:10px 14px;margin-bottom:16px;font-size:13px;color:#7aa7d4;border-radius:4px">Distributed as a press release — staff editorial rewrite pending.</aside>`;
const content = NOTICE + (opts.rawHtml || `<p>${opts.rawContent.replace(/\n+/g, "</p><p>")}</p>`);
const excerpt = opts.rawContent.replace(/\s+/g, " ").slice(0, 240).trim();
const wp_id = -Math.floor(Date.now() / 1000);
const { error } = await sb.from("wp_imported_posts").insert({
wp_id,
wp_slug,
title: opts.rawTitle,
excerpt,
content,
author_name: opts.contributorName || "PR Submission",
category: "industry",
tags: [] as string[],
featured_image: opts.featuredImageUrl || null,
status: "published",
wp_published_at: new Date().toISOString(),
imported_at: new Date().toISOString(),
ai_rewritten_at: null,
site_id: 1,
});
if (error) {
console.warn(`[publish-raw-fallback] insert failed: ${error.message}`);
return null;
}
return { wp_id, wp_slug };
} catch (e: any) {
console.warn(`[publish-raw-fallback] unhandled: ${e?.message || e}`);
return null;
}
}

View File

@@ -27,10 +27,18 @@ export function rewriteLegacyImageUrl(url: string | null | undefined): string {
}
export function rewriteLegacyImageUrlsInHtml(html: string): string {
return html.replace(
let out = html.replace(
/(https?:\/\/(?:www\.)?broadcastbeat\.com\/wp-content\/uploads\/[^"'\s)]+)/g,
(m) => rewriteLegacyImageUrl(m),
);
// Rewrite raw Supabase Storage URLs to clean first-party /img/<bucket>/<path>
// so article HTML never leaks the long supabase.onsethost.com URL. Better for
// SEO and keeps images visually first-party. Served by /img/[...path]/route.ts.
out = out.replace(
/https?:\/\/(?:[\w-]+\.)*supabase\.onsethost\.com\/storage\/v1\/object\/public\/([^\s"')]+)/g,
(_m, p) => `/img/${p}`,
);
return out;
}
/**