From dea847a158714a61a2f8e49b8b95a67b5f3841da Mon Sep 17 00:00:00 2001 From: Ben Bracamonte Date: Sat, 30 May 2026 18:20:09 -0500 Subject: [PATCH 1/2] fix(scout,listing,dashboard): JSON fence parsing, inventory quantity coverage, per-listing publish + Etsy image panel Scout: - Replace startswith-only code-fence stripper with a regex extractor that handles a prose preamble before the ```json fence and requires the closing fence on its own line (so a literal ``` inside a JSON value can't truncate the payload). Add a "raw JSON only, no fences" line to SYSTEM_PROMPT and a log.warning when the fallback fires. Add preamble + inline-backtick + bare-JSON unit tests. Error-row persistence behavior is unchanged. Listing (quantity regression coverage; source already correct): - Add an MSW handler for the Etsy inventory PUT that 400s when any offering lacks a numeric quantity > 0, and a unit test asserting every offering carries POD_VARIANT_QUANTITY (999) and round-trips EtsyInventoryInputSchema. Per-listing publish (cost-aware): - Export resumePublish from @presswork/listing and add it as a dashboard workspace dep (transpilePackages). New publishListingNow server action (owner-auth + pending_publish stale guard). Detail page gains a confirmed "Publish to Etsy ($0.20 listing fee)" button, locked while the agent runs; "Approve & publish" relabeled to "Approve". Compliance gates still run. Etsy image management: - Add getListingImages/deleteListingImage to shared (+ mock fixtures, 204 handling) and tighten offering quantity schema to .positive(). New dashboard API routes (GET/POST/DELETE) and EtsyImagePanel to view live Etsy images and add/delete from the on-file pool (design PNG + mockups). POST allowlists https on Supabase/Printify/Dynamic-Mockups hosts to prevent SSRF. --- package-lock.json | 1 + .../[id]/etsy-images/[imageId]/route.ts | 57 +++ .../api/listings/[id]/etsy-images/route.ts | 154 ++++++++ packages/dashboard/app/listings/[id]/page.tsx | 86 ++++- packages/dashboard/app/listings/page.tsx | 2 +- .../components/listings/EtsyImagePanel.tsx | 345 ++++++++++++++++++ packages/dashboard/lib/actions/listings.ts | 49 ++- packages/dashboard/next.config.ts | 8 +- packages/dashboard/package.json | 1 + packages/listing/package.json | 8 + packages/listing/src/index.ts | 2 + packages/listing/src/inventory.test.ts | 20 + .../tests/integration/publisher-flow.test.ts | 24 ++ packages/scout/analyzer.py | 31 +- packages/scout/test_analyzer.py | 30 +- packages/shared/src/etsy-api.ts | 62 +++- packages/shared/src/etsy-mock.ts | 49 ++- 17 files changed, 901 insertions(+), 28 deletions(-) create mode 100644 packages/dashboard/app/api/listings/[id]/etsy-images/[imageId]/route.ts create mode 100644 packages/dashboard/app/api/listings/[id]/etsy-images/route.ts create mode 100644 packages/dashboard/components/listings/EtsyImagePanel.tsx diff --git a/package-lock.json b/package-lock.json index bc8f50d..5bcdc7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9897,6 +9897,7 @@ "version": "0.1.0", "dependencies": { "@anthropic-ai/sdk": "^0.30.0", + "@presswork/listing": "*", "@presswork/shared": "*", "@supabase/ssr": "^0.5.0", "@supabase/supabase-js": "^2.45.0", diff --git a/packages/dashboard/app/api/listings/[id]/etsy-images/[imageId]/route.ts b/packages/dashboard/app/api/listings/[id]/etsy-images/[imageId]/route.ts new file mode 100644 index 0000000..751c02f --- /dev/null +++ b/packages/dashboard/app/api/listings/[id]/etsy-images/[imageId]/route.ts @@ -0,0 +1,57 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { requireOwnerEmail } from "@/lib/auth"; +import { serviceClient } from "@/lib/supabase/server"; +import { deleteListingImage } from "@presswork/shared"; + +export const dynamic = "force-dynamic"; + +/** + * DELETE /api/listings/[id]/etsy-images/[imageId] + * Removes a single image from the live Etsy listing. + * imageId is the Etsy listing_image_id (numeric). + */ +export async function DELETE( + _req: NextRequest, + ctx: { params: Promise<{ id: string; imageId: string }> } +) { + if (!(await requireOwnerEmail())) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { id, imageId: imageIdStr } = await ctx.params; + const imageId = Number(imageIdStr); + if (!Number.isInteger(imageId) || imageId <= 0) { + return NextResponse.json( + { error: "imageId must be a positive integer" }, + { status: 400 } + ); + } + + const db = serviceClient(); + + const { data: listing, error } = await db + .from("listings") + .select("etsy_listing_id") + .eq("id", id) + .maybeSingle(); + + if (error || !listing) { + return NextResponse.json({ error: "Listing not found" }, { status: 404 }); + } + + const etsyListingId = listing.etsy_listing_id as number | null; + if (!etsyListingId) { + return NextResponse.json( + { error: "Listing has no etsy_listing_id — not yet published to Etsy" }, + { status: 409 } + ); + } + + try { + await deleteListingImage(db, etsyListingId, imageId); + return NextResponse.json({ ok: true }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to delete image from Etsy"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/packages/dashboard/app/api/listings/[id]/etsy-images/route.ts b/packages/dashboard/app/api/listings/[id]/etsy-images/route.ts new file mode 100644 index 0000000..5121d81 --- /dev/null +++ b/packages/dashboard/app/api/listings/[id]/etsy-images/route.ts @@ -0,0 +1,154 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { requireOwnerEmail } from "@/lib/auth"; +import { serviceClient } from "@/lib/supabase/server"; +import { + getListingImages, + uploadListingImage, + getListingImageCount, +} from "@presswork/shared"; + +export const dynamic = "force-dynamic"; + +/** GET /api/listings/[id]/etsy-images — returns the images currently live on the Etsy listing. */ +export async function GET( + _req: NextRequest, + ctx: { params: Promise<{ id: string }> } +) { + if (!(await requireOwnerEmail())) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { id } = await ctx.params; + const db = serviceClient(); + + const { data: listing, error } = await db + .from("listings") + .select("etsy_listing_id") + .eq("id", id) + .maybeSingle(); + + if (error || !listing) { + return NextResponse.json({ error: "Listing not found" }, { status: 404 }); + } + + const etsyListingId = listing.etsy_listing_id as number | null; + if (!etsyListingId) { + return NextResponse.json( + { error: "Listing has no etsy_listing_id — not yet published to Etsy" }, + { status: 409 } + ); + } + + try { + const images = await getListingImages(db, etsyListingId); + return NextResponse.json({ images }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to fetch Etsy images"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +// uploadListingImage fetches imageUrl server-side, so an unconstrained URL is an +// SSRF vector (e.g. http://169.254.169.254/...). Restrict to https on the hosts we +// actually serve listing images from: Supabase Storage (design PNG) and the mockup +// CDNs (Printify, Dynamic Mockups). +const TRUSTED_IMAGE_HOST_SUFFIXES = [ + ".supabase.co", + ".printify.com", + ".dynamicmockups.com", +] as const; + +function isTrustedImageUrl(raw: string): boolean { + let url: URL; + try { + url = new URL(raw); + } catch { + return false; + } + if (url.protocol !== "https:") return false; + const host = url.hostname.toLowerCase(); + return TRUSTED_IMAGE_HOST_SUFFIXES.some( + (suffix) => host === suffix.slice(1) || host.endsWith(suffix) + ); +} + +const PostBodySchema = z.object({ + imageUrl: z + .string() + .url() + .refine(isTrustedImageUrl, { + message: + "imageUrl must be an https URL on a trusted image host (Supabase Storage, Printify, or Dynamic Mockups)", + }), + rank: z.number().int().positive().optional(), + altText: z.string().max(250).optional(), +}); + +/** POST /api/listings/[id]/etsy-images — adds an image to the live Etsy listing. */ +export async function POST( + req: NextRequest, + ctx: { params: Promise<{ id: string }> } +) { + if (!(await requireOwnerEmail())) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + const { id } = await ctx.params; + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = PostBodySchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request body", details: parsed.error.flatten() }, + { status: 400 } + ); + } + const { imageUrl, rank: requestedRank, altText } = parsed.data; + + const db = serviceClient(); + + const { data: listing, error } = await db + .from("listings") + .select("etsy_listing_id, title") + .eq("id", id) + .maybeSingle(); + + if (error || !listing) { + return NextResponse.json({ error: "Listing not found" }, { status: 404 }); + } + + const etsyListingId = listing.etsy_listing_id as number | null; + if (!etsyListingId) { + return NextResponse.json( + { error: "Listing has no etsy_listing_id — not yet published to Etsy" }, + { status: 409 } + ); + } + + try { + // Default rank to current count + 1 so the new image appends to the end. + const rank = + requestedRank ?? (await getListingImageCount(db, etsyListingId)) + 1; + + const resolvedAltText = + altText ?? + (listing.title ? `Product photo: ${listing.title}` : undefined); + + await uploadListingImage(db, etsyListingId, imageUrl, { + rank, + altText: resolvedAltText, + }); + + return NextResponse.json({ ok: true }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to upload image to Etsy"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/packages/dashboard/app/listings/[id]/page.tsx b/packages/dashboard/app/listings/[id]/page.tsx index e878381..92dcb8f 100644 --- a/packages/dashboard/app/listings/[id]/page.tsx +++ b/packages/dashboard/app/listings/[id]/page.tsx @@ -4,6 +4,7 @@ import { SurfaceCard } from "@/components/ui/SurfaceCard"; import { Button } from "@/components/ui/Button"; import { StatusBadge } from "@/components/status/StatusBadge"; import { MockupCarousel } from "@/components/listings/MockupCarousel"; +import { EtsyImagePanel } from "@/components/listings/EtsyImagePanel"; import { ComplianceChecks } from "@/components/listings/ComplianceChecks"; import { EtsyPayloadPreview } from "@/components/listings/EtsyPayloadPreview"; import { CopyEditor } from "@/components/listings/CopyEditor"; @@ -12,6 +13,7 @@ import { DynamicMockupsTrigger } from "@/components/listings/DynamicMockupsTrigg import { SubmitButton } from "@/components/ui/SubmitButton"; import { approveListing, + publishListingNow, pushListingToEtsy, recreatePrintifyProduct, regenerateCopy, @@ -20,6 +22,7 @@ import { } from "@/lib/actions/listings"; import { getListing, type ListingWithDesign } from "@/lib/queries/listings"; import { getEtsyPayloadPreview } from "@/lib/queries/etsy-preview"; +import { getIsListingAgentRunning } from "@/lib/actions/triggers"; import { formatRelative, formatUsd } from "@/lib/format"; export const dynamic = "force-dynamic"; @@ -30,9 +33,12 @@ interface Params { export default async function ListingDetailPage({ params }: Params) { const { id } = await params; - const listing = await getListing(id); + const [listing, etsyPreview, isAgentRunning] = await Promise.all([ + getListing(id), + getEtsyPayloadPreview(id), + getIsListingAgentRunning(), + ]); if (!listing) notFound(); - const etsyPreview = await getEtsyPayloadPreview(id); const etsyHref = listing.etsy_listing_id ? `https://www.etsy.com/listing/${listing.etsy_listing_id}` @@ -92,6 +98,15 @@ export default async function ListingDetailPage({ params }: Params) { + {listing.status === "active" && listing.etsy_listing_id && ( + + )} + )} @@ -266,6 +281,13 @@ export default async function ListingDetailPage({ params }: Params) { + + {listing.status === "pending_publish" && ( + + )} @@ -323,3 +345,61 @@ function PushToEtsyForm({ listing }: { listing: ListingWithDesign }) { ); } + +/** + * Confirmation card for per-listing publish. Shown only when status is + * 'pending_publish'. Uses the
popover pattern from the Reject card + * so no JS is required and the operator sees the cost warning before clicking. + * + * Disabled when the listing agent is already actively publishing (isAgentRunning), + * matching the Run Listing button lock so both the agent and this button can't + * race against the same row at the same time. + */ +function PublishNowCard({ + listingId, + isAgentRunning, +}: { + listingId: string; + isAgentRunning: boolean; +}) { + return ( + + {isAgentRunning ? ( +

+ Locked while the listing agent is running. +

+ ) : ( +
+ + Publish now… + +
+ +

+ This will POST a new Etsy listing draft, upload mockup images, and + activate the listing. Etsy charges a{" "} + $0.20 listing fee{" "} + per listing. +

+ + +
+ )} +
+ ); +} diff --git a/packages/dashboard/app/listings/page.tsx b/packages/dashboard/app/listings/page.tsx index 4f45677..6f1b405 100644 --- a/packages/dashboard/app/listings/page.tsx +++ b/packages/dashboard/app/listings/page.tsx @@ -95,7 +95,7 @@ export default async function ListingsPage() { {pendingPublish.length > 0 && (
{pendingPublish.map((l) => ( diff --git a/packages/dashboard/components/listings/EtsyImagePanel.tsx b/packages/dashboard/components/listings/EtsyImagePanel.tsx new file mode 100644 index 0000000..4ba0dc4 --- /dev/null +++ b/packages/dashboard/components/listings/EtsyImagePanel.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useState, useCallback } from "react"; +import type { EtsyListingImage } from "@presswork/shared"; +import { SurfaceCard } from "@/components/ui/SurfaceCard"; +import { Button } from "@/components/ui/Button"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +// Single source of truth lives in @presswork/shared; alias keeps local usages terse +// and prevents the panel's shape from silently diverging from the API response. +type EtsyImage = EtsyListingImage; + +interface EtsyImagePanelProps { + /** Internal UUID of the listing row in our DB. */ + listingId: string; + /** Numeric Etsy listing ID. Panel is only rendered when this is set. */ + etsyListingId: number; + /** URL of the design PNG stored in Supabase Storage. */ + designImageUrl: string | null; + /** Array of Printify mockup CDN URLs. */ + mockupUrls: string[] | null; +} + +// ── API helpers ──────────────────────────────────────────────────────────────── + +async function fetchImages(listingId: string): Promise { + const res = await fetch(`/api/listings/${listingId}/etsy-images`, { + cache: "no-store", + }); + if (!res.ok) { + const data: unknown = await res.json().catch(() => null); + const msg = + data !== null && + typeof data === "object" && + "error" in data && + typeof (data as { error: unknown }).error === "string" + ? (data as { error: string }).error + : `HTTP ${res.status}`; + throw new Error(msg); + } + const json: unknown = await res.json(); + if ( + typeof json !== "object" || + json === null || + !("images" in json) || + !Array.isArray((json as { images: unknown }).images) + ) { + throw new Error("Unexpected response shape from /etsy-images"); + } + return (json as { images: EtsyImage[] }).images; +} + +async function addImage( + listingId: string, + imageUrl: string +): Promise { + const res = await fetch(`/api/listings/${listingId}/etsy-images`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ imageUrl }), + }); + if (!res.ok) { + const data: unknown = await res.json().catch(() => null); + const msg = + data !== null && + typeof data === "object" && + "error" in data && + typeof (data as { error: unknown }).error === "string" + ? (data as { error: string }).error + : `HTTP ${res.status}`; + throw new Error(msg); + } +} + +async function removeImage( + listingId: string, + imageId: number +): Promise { + const res = await fetch( + `/api/listings/${listingId}/etsy-images/${imageId}`, + { method: "DELETE" } + ); + if (!res.ok) { + const data: unknown = await res.json().catch(() => null); + const msg = + data !== null && + typeof data === "object" && + "error" in data && + typeof (data as { error: unknown }).error === "string" + ? (data as { error: string }).error + : `HTTP ${res.status}`; + throw new Error(msg); + } +} + +// ── Sub-components ───────────────────────────────────────────────────────────── + +interface LiveImageRowProps { + image: EtsyImage; + onDelete: (imageId: number) => Promise; +} + +function LiveImageRow({ image, onDelete }: LiveImageRowProps) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const handleDelete = useCallback(async () => { + setPending(true); + setError(null); + try { + await onDelete(image.listing_image_id); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Delete failed"); + } finally { + setPending(false); + } + }, [image.listing_image_id, onDelete]); + + return ( +
  • + {/* Thumbnail */} + {/* eslint-disable-next-line @next/next/no-img-element */} + {image.alt_text +
    +

    Rank {image.rank}

    + {image.alt_text && ( +

    + {image.alt_text} +

    + )} + {error && ( +

    {error}

    + )} +
    + +
  • + ); +} + +interface AvailableImageRowProps { + url: string; + label: string; + onAdd: (url: string) => Promise; +} + +function AvailableImageRow({ url, label, onAdd }: AvailableImageRowProps) { + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + const handleAdd = useCallback(async () => { + setPending(true); + setError(null); + try { + await onAdd(url); + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Add failed"); + } finally { + setPending(false); + } + }, [url, onAdd]); + + return ( +
  • + {/* eslint-disable-next-line @next/next/no-img-element */} + {label} +
    +

    {label}

    + {error && ( +

    {error}

    + )} +
    + +
  • + ); +} + +// ── Main panel ───────────────────────────────────────────────────────────────── + +export function EtsyImagePanel({ + listingId, + designImageUrl, + mockupUrls, +}: EtsyImagePanelProps) { + const [expanded, setExpanded] = useState(false); + const [loading, setLoading] = useState(false); + const [liveImages, setLiveImages] = useState(null); + const [fetchError, setFetchError] = useState(null); + + // Build the available sources list: design PNG first, then mockups. + const availableSources: Array<{ url: string; label: string }> = []; + if (designImageUrl) { + availableSources.push({ url: designImageUrl, label: "Design PNG" }); + } + (mockupUrls ?? []).forEach((url, i) => { + availableSources.push({ url, label: `Mockup ${i + 1}` }); + }); + + const load = useCallback(async () => { + setLoading(true); + setFetchError(null); + try { + const images = await fetchImages(listingId); + setLiveImages(images); + } catch (err: unknown) { + setFetchError(err instanceof Error ? err.message : "Failed to load images"); + } finally { + setLoading(false); + } + }, [listingId]); + + const handleExpand = useCallback(async () => { + if (!expanded) { + setExpanded(true); + await load(); + } else { + setExpanded(false); + } + }, [expanded, load]); + + const handleDelete = useCallback( + async (imageId: number) => { + await removeImage(listingId, imageId); + // Optimistic: remove from local list immediately, then re-fetch for + // accurate rank ordering after Etsy reassigns ranks. + await load(); + }, + [listingId, load] + ); + + const handleAdd = useCallback( + async (imageUrl: string) => { + await addImage(listingId, imageUrl); + await load(); + }, + [listingId, load] + ); + + return ( + +
    + {/* Expand / collapse toggle */} + + + {expanded && ( + <> + {/* Atomic-swap notice */} +

    + Note: adds and deletes are individual API calls — the Etsy listing + may briefly show fewer images during a swap. Reorder is not + supported here; use Etsy's shop manager to change image order. +

    + + {/* Loading / error state */} + {loading && ( +

    Loading…

    + )} + {fetchError && ( +

    {fetchError}

    + )} + + {/* Live images */} + {liveImages !== null && ( +
    +

    + Live on Etsy ({liveImages.length}) +

    + {liveImages.length === 0 ? ( +

    + No images on this listing yet. +

    + ) : ( +
      + {liveImages.map((img) => ( + + ))} +
    + )} +
    + )} + + {/* Available source images */} + {availableSources.length > 0 && ( +
    +

    + Available images ({availableSources.length}) +

    +
      + {availableSources.map(({ url, label }) => ( + + ))} +
    +
    + )} + + )} +
    +
    + ); +} diff --git a/packages/dashboard/lib/actions/listings.ts b/packages/dashboard/lib/actions/listings.ts index a32e630..3c26519 100644 --- a/packages/dashboard/lib/actions/listings.ts +++ b/packages/dashboard/lib/actions/listings.ts @@ -5,8 +5,6 @@ import { z } from "zod"; // Print cost for the only currently-supported blueprint (Gildan 64000). // Mirrors GILDAN_64000_PRINT_COST_USD in packages/listing/src/constants.ts. -// Inlined here to avoid the dashboard depending on the listing CLI package. -// When a second blueprint lands, lift this into shared and key by blueprint id. const PRINT_COST_USD = 8.5; const PRICING_FLOOR_MULTIPLIER = 2.5; const PRICE_FLOOR_USD = PRINT_COST_USD * PRICING_FLOOR_MULTIPLIER; // $21.25 @@ -23,6 +21,7 @@ import { renderMockup, DynamicMockupsApiError, } from "@presswork/shared"; +import { resumePublish } from "@presswork/listing"; import { serviceClient } from "@/lib/supabase/server"; import { requireOwnerEmail } from "@/lib/auth"; @@ -790,3 +789,49 @@ export async function deleteListing(formData: FormData) { throw new Error(`Listing deleted, but ${sendBackWarning}.`); } } + +/** + * Publish a single listing to Etsy immediately, charging the $0.20 Etsy + * listing fee. Only valid when the listing is at status='pending_publish'. + * + * Calls resumePublish from @presswork/listing, which drives the full publish + * pipeline: compliance gates, createDraftListing, inventory PUT, image upload, + * activateListing, Printify setProductVisible. The listing transitions through + * 'publishing' → 'active' (or back to 'pending_publish'/'error' on failure). + * + * The dashboard's runtime env must include: + * ETSY_PRODUCTION_PARTNER_ID, ETSY_SHIPPING_PROFILE_ID, + * ETSY_READINESS_STATE_ID, ETSY_ACCESS_TOKEN, ETSY_REFRESH_TOKEN, + * ETSY_API_KEY, ETSY_API_SECRET, ETSY_SHOP_ID, PRINTIFY_API_TOKEN + * Set ETSY_MOCK_MODE=true to exercise the full pipeline without live Etsy + * credentials (see packages/shared/src/etsy-mock.ts). + */ +export async function publishListingNow(formData: FormData): Promise { + await assertOwner(); + const id = idSchema.parse(formData.get("id")); + const db = serviceClient(); + + // Stale-tab guard: confirm the listing is still at pending_publish before + // incurring the $0.20 Etsy fee. Mirrors the approveListing guard pattern. + const { data: row } = await db + .from("listings") + .select("status") + .eq("id", id) + .maybeSingle(); + if (!row) throw new Error("Listing not found"); + if (row.status !== "pending_publish") { + throw new Error( + `Cannot publish: listing is at status='${row.status}', expected 'pending_publish'. ` + + "Refresh the page and try again." + ); + } + + // resumePublish drives the full Etsy publish pipeline and owns all DB writes + // on both success and failure paths. On failure it sets the row back to + // 'pending_publish' (or 'error' after MAX_RETRIES) and rethrows so the + // server action surfaces the error to the UI. + await resumePublish(db, id); + + revalidatePath("/listings"); + revalidatePath(`/listings/${id}`); +} diff --git a/packages/dashboard/next.config.ts b/packages/dashboard/next.config.ts index 7a4587b..e339693 100644 --- a/packages/dashboard/next.config.ts +++ b/packages/dashboard/next.config.ts @@ -12,9 +12,11 @@ const config: NextConfig = { bodySizeLimit: "25mb", }, }, - // The dashboard pulls types from @presswork/shared as a workspace package; - // tell Next to transpile it instead of treating it as a prebuilt dep. - transpilePackages: ["@presswork/shared"], + // The dashboard pulls types from @presswork/shared and @presswork/listing as + // workspace packages; tell Next to transpile them instead of treating them as + // prebuilt deps. @presswork/listing exposes resumePublish for per-listing + // publish from the dashboard without requiring a full agent run. + transpilePackages: ["@presswork/shared", "@presswork/listing"], images: { remotePatterns: [ // fal.ai design output, Supabase Storage (design PNGs), Printify mockups diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 0eb1c32..556e995 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.30.0", + "@presswork/listing": "*", "@presswork/shared": "*", "@supabase/ssr": "^0.5.0", "@supabase/supabase-js": "^2.45.0", diff --git a/packages/listing/package.json b/packages/listing/package.json index 1d5bfd5..b2fabf8 100644 --- a/packages/listing/package.json +++ b/packages/listing/package.json @@ -2,6 +2,14 @@ "name": "@presswork/listing", "version": "0.1.0", "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } + }, "scripts": { "build": "tsc -b", "start": "node --import tsx/esm src/index.ts", diff --git a/packages/listing/src/index.ts b/packages/listing/src/index.ts index aec325c..a77664f 100644 --- a/packages/listing/src/index.ts +++ b/packages/listing/src/index.ts @@ -2,6 +2,8 @@ import { getDb, getLogger } from "@presswork/shared"; import { claimNextDesignPackage } from "./poller.js"; import { publishOne, resumePublish } from "./publisher.js"; +export { resumePublish } from "./publisher.js"; + async function fetchTrendBrief(db: ReturnType, trendBriefId: string) { const { data, error } = await db .from("trend_briefs") diff --git a/packages/listing/src/inventory.test.ts b/packages/listing/src/inventory.test.ts index 92c8acc..20cb999 100644 --- a/packages/listing/src/inventory.test.ts +++ b/packages/listing/src/inventory.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { buildInventoryFromDesign, InventoryMappingError } from "./inventory.js"; +import { POD_VARIANT_QUANTITY, EtsyInventoryInputSchema } from "@presswork/shared"; const baseDesign = { printify_blueprint_id: 145, @@ -94,4 +95,23 @@ describe("buildInventoryFromDesign", () => { }) ).toThrow(/expects 2 axes/); }); + + it("regression: every offering quantity equals POD_VARIANT_QUANTITY (999) — Etsy returns 400 when quantity is missing", () => { + // Guard against any future regression that drops the quantity field from + // offerings. The MSW handler in publisher-flow.test.ts mirrors Etsy's + // real 400 response for missing/zero quantity, so this unit-level check + // catches the same regression without needing the integration flag. + const inv = buildInventoryFromDesign({ design: baseDesign, priceUsd: 24.99 }); + + // Every offering must carry exactly POD_VARIANT_QUANTITY = 999. + for (const product of inv.products) { + for (const offering of product.offerings) { + expect(offering.quantity).toBe(POD_VARIANT_QUANTITY); + expect(offering.quantity).toBe(999); + } + } + + // The full shape must satisfy the Etsy inventory schema without throwing. + expect(() => EtsyInventoryInputSchema.parse(inv)).not.toThrow(); + }); }); diff --git a/packages/listing/tests/integration/publisher-flow.test.ts b/packages/listing/tests/integration/publisher-flow.test.ts index cc33bdd..6017db8 100644 --- a/packages/listing/tests/integration/publisher-flow.test.ts +++ b/packages/listing/tests/integration/publisher-flow.test.ts @@ -94,6 +94,30 @@ function baseHandlers(activateFails = false) { http.post(`https://openapi.etsy.com/v3/application/shops/${SHOP_ID}/listings`, () => HttpResponse.json({ listing_id: ETSY_LISTING_ID, state: "draft", title: VALID_COPY.title }) ), + // Etsy — inventory PUT. Validates every product offering has a numeric + // quantity so a missing/zero quantity regresses to HTTP 400 before any + // Etsy call succeeds. + http.put( + `https://openapi.etsy.com/v3/application/listings/:id/inventory`, + async ({ request }) => { + const body = await request.json() as { products?: Array<{ offerings?: Array<{ quantity?: unknown }> }> }; + const products = body?.products ?? []; + const allHaveQuantity = products.every( + (p) => + Array.isArray(p.offerings) && + p.offerings.every( + (o) => typeof o.quantity === "number" && o.quantity > 0 + ) + ); + if (!allHaveQuantity) { + return HttpResponse.json( + { error: "every product offering must have a numeric quantity > 0" }, + { status: 400 } + ); + } + return HttpResponse.json({ products }); + } + ), // Etsy — upload image (stub the image download too) http.get("https://printify.com/:path*", () => new HttpResponse(new Uint8Array([137, 80, 78, 71]).buffer, { diff --git a/packages/scout/analyzer.py b/packages/scout/analyzer.py index b1ecab3..710561b 100644 --- a/packages/scout/analyzer.py +++ b/packages/scout/analyzer.py @@ -1,4 +1,5 @@ import json +import re import anthropic import structlog @@ -11,21 +12,24 @@ def _strip_code_fences(text: str) -> str: - """Remove a Markdown ```/```json code fence Claude sometimes wraps JSON in. + """Extract JSON from a Markdown code fence Claude sometimes wraps it in. - The model is told to respond with raw JSON, but intermittently fences it. - Stripping here keeps json.loads from failing on otherwise-valid output. + Handles a leading prose preamble before the fence (e.g. "Here is the + analysis:\\n```json\\n{...}\\n```"), a plain ``` fence, or bare JSON with no + fence at all. The regex searches anywhere in the response so position of the + fence does not matter. """ stripped = text.strip() - if not stripped.startswith("```"): - return stripped - # Drop the opening fence line (``` or ```json) and the closing fence. - if "\n" in stripped: - stripped = stripped.split("\n", 1)[1] - stripped = stripped.rstrip() - if stripped.endswith("```"): - stripped = stripped[:-3] - return stripped.strip() + # Require the closing fence on its own line (``\n```\``) rather than ``\n?```\`` + # so a literal ``` sequence *inside* a JSON value cannot terminate the match + # early and truncate the payload. + m = re.search(r"```(?:json)?\s*\n([\s\S]*?)\n```", stripped) + if m: + # Claude was told to emit raw JSON (see SYSTEM_PROMPT); log when it + # disobeys so we can track how often this fallback path is exercised. + log.warning("scout_claude_fenced_response", action="strip_code_fences") + return m.group(1).strip() + return stripped # How many listings per niche send their thumbnail to Claude. Etsy already @@ -47,7 +51,8 @@ def _strip_code_fences(text: str) -> str: "price_target_usd": float, "color_palette": [str] // hex or color names } -Never reference specific shop names, artist names, or existing IP.""" +Never reference specific shop names, artist names, or existing IP. +Respond with raw JSON only — do NOT wrap the output in Markdown code fences or add any commentary before or after the JSON.""" VISION_SYSTEM_ADDENDUM = """ diff --git a/packages/scout/test_analyzer.py b/packages/scout/test_analyzer.py index 3ecec5d..d77df7b 100644 --- a/packages/scout/test_analyzer.py +++ b/packages/scout/test_analyzer.py @@ -6,7 +6,7 @@ import pytest from pydantic import ValidationError -from packages.scout.analyzer import analyze_niche +from packages.scout.analyzer import _strip_code_fences, analyze_niche from packages.shared_py.models import ClaudeAnalysis _VALID_ANALYSIS = { @@ -88,6 +88,34 @@ async def test_fenced_json_response_is_parsed(mocker): assert result.niche == "dog mom gifts" +@pytest.mark.asyncio +async def test_fenced_json_with_preamble_is_parsed(mocker): + """Prose preamble before the ```json fence must not break parsing.""" + fenced = f"Here is the analysis:\n```json\n{json.dumps(_VALID_ANALYSIS)}\n```" + _mock_client(mocker, fenced) + result = await analyze_niche(_SAMPLE_LISTINGS) + assert isinstance(result, ClaudeAnalysis) + assert result.niche == "dog mom gifts" + + +def test_strip_code_fences_preserves_inline_backticks(): + """A literal ``` inside a JSON value must not terminate the fence early. + + The closing fence must sit on its own line, so a backtick run embedded in a + string value is kept intact and the JSON round-trips cleanly. + """ + payload = {"niche": "code humor", "note": "use ``` for blocks"} + fenced = f"```json\n{json.dumps(payload)}\n```" + extracted = _strip_code_fences(fenced) + assert json.loads(extracted)["note"] == "use ``` for blocks" + + +def test_strip_code_fences_passes_through_bare_json(): + """Responses with no fence fall through unchanged.""" + bare = json.dumps(_VALID_ANALYSIS) + assert _strip_code_fences(bare) == bare + + @pytest.mark.asyncio async def test_invalid_json_raises_value_error(mocker): _mock_client(mocker, "here is my analysis: sorry, not JSON") diff --git a/packages/shared/src/etsy-api.ts b/packages/shared/src/etsy-api.ts index 160747f..56ac16b 100644 --- a/packages/shared/src/etsy-api.ts +++ b/packages/shared/src/etsy-api.ts @@ -88,6 +88,12 @@ export async function etsyFetch(db: Db, path: string, init: RequestInit = {}): P throw new EtsyApiError(`Etsy ${res.status}: ${body}`, res.status); } + // 204 No Content (e.g. DELETE) has no body — return null rather than + // throwing a JSON parse error. + if (res.status === 204 || res.headers.get("content-length") === "0") { + return null; + } + return res.json(); }, { retries: 3, factor: 2, minTimeout: 500 } @@ -372,9 +378,23 @@ export async function uploadListingImage( ); } +// Full image shape returned by GET /listings/{id}/images. +// url_570xN is the standard display size; url_fullxfull is the original upload +// (may be absent on older images). alt_text was added in a later API version +// so we default to null to avoid parse failures on legacy listing images. +export const EtsyListingImageSchema = z.object({ + listing_image_id: z.number(), + rank: z.number().int(), + url_570xN: z.string().url(), + url_fullxfull: z.string().url().optional(), + alt_text: z.string().nullable().optional(), +}); + +export type EtsyListingImage = z.infer; + const EtsyListingImagesResponseSchema = z.object({ count: z.number().int().nonnegative(), - results: z.array(z.object({ listing_image_id: z.number(), rank: z.number().int() })), + results: z.array(EtsyListingImageSchema), }); /** @@ -392,6 +412,42 @@ export async function getListingImageCount(db: Db, listingId: number): Promise { + const { ETSY_SHOP_ID } = getSettings(); + const data = await etsyFetch( + db, + `/application/shops/${ETSY_SHOP_ID}/listings/${listingId}/images` + ); + const { results } = EtsyListingImagesResponseSchema.parse(data); + return [...results].sort((a, b) => a.rank - b.rank); +} + +/** + * Deletes a single image from an Etsy listing. + * DELETE /application/shops/{shop_id}/listings/{listing_id}/images/{image_id} + * Etsy returns 204 No Content on success; etsyFetch accepts that as ok. + */ +export async function deleteListingImage( + db: Db, + listingId: number, + imageId: number +): Promise { + const { ETSY_SHOP_ID } = getSettings(); + await etsyFetch( + db, + `/application/shops/${ETSY_SHOP_ID}/listings/${listingId}/images/${imageId}`, + { method: "DELETE" } + ); +} + export async function activateListing( db: Db, listingId: number @@ -560,7 +616,9 @@ export async function deactivateEtsyListing(db: Db, listingId: number): Promise< const EtsyOfferingSchema = z.object({ price: z.number().positive(), - quantity: z.number().int().nonnegative(), + // Etsy rejects quantity=0 on POD listings. Use .positive() (≥1) to catch + // misconfigured variants before the API call fails. + quantity: z.number().int().positive(), is_enabled: z.boolean(), }); diff --git a/packages/shared/src/etsy-mock.ts b/packages/shared/src/etsy-mock.ts index 2f597ff..75d20fa 100644 --- a/packages/shared/src/etsy-mock.ts +++ b/packages/shared/src/etsy-mock.ts @@ -194,16 +194,59 @@ const fixtures: FixtureEntry[] = [ }, }, + // Delete a single listing image (more-specific path — must come before the + // GET list and the POST upload fixtures so DELETE isn't swallowed by them). + // DELETE /application/shops/{shop_id}/listings/{listing_id}/images/{image_id} + // Etsy returns 204 No Content; we echo the image_id back so callers can log it. + { + name: "delete_listing_image", + match: ({ method, path }) => + method === "DELETE" && + /\/application\/shops\/[^/]+\/listings\/\d+\/images\/\d+\/?$/.test(path), + build: ({ path }) => { + const idMatch = path.match(/images\/(\d+)/); + const imageId = idMatch ? Number(idMatch[1]) : 0; + return { listing_image_id: imageId }; + }, + }, + // List a listing's images — idempotency guard for the publish/resume path. // GET /application/shops/{shop_id}/listings/{listing_id}/images - // Returns count 0 so a mock publish always uploads its full mockup set (no - // skip); a real retry against Etsy sees the true count and skips uploaded ranks. + // Returns a small populated set so the dashboard "Etsy listing images" panel + // has real image IDs to work with in mock mode. The count must equal + // results.length so getListingImageCount and getListingImages are consistent. + // A mock publish still re-uploads its full mockup set because the ranks here + // start at 10 and the publisher uploads starting from rank 1 (no overlap). { name: "list_listing_images", match: ({ method, path }) => method === "GET" && /\/application\/shops\/[^/]+\/listings\/[^/]+\/images\/?$/.test(path), - build: () => ({ count: 0, results: [] }), + build: ({ path }) => { + const idMatch = path.match(/listings\/(\d+)\/images/); + const listingId = idMatch ? Number(idMatch[1]) : 0; + const imageId1 = deterministicId(`img1:${listingId}`, 999_999_998) + 1; + const imageId2 = deterministicId(`img2:${listingId}`, 999_999_998) + 1; + return { + count: 2, + results: [ + { + listing_image_id: imageId1, + rank: 1, + url_570xN: `https://i.etsystatic.com/mock/${imageId1}_570xN.jpg`, + url_fullxfull: `https://i.etsystatic.com/mock/${imageId1}_fullxfull.jpg`, + alt_text: "Mock design image rank 1", + }, + { + listing_image_id: imageId2, + rank: 2, + url_570xN: `https://i.etsystatic.com/mock/${imageId2}_570xN.jpg`, + url_fullxfull: `https://i.etsystatic.com/mock/${imageId2}_fullxfull.jpg`, + alt_text: "Mock design image rank 2", + }, + ], + }; + }, }, // Upload listing image (multipart) From 138bfea486ac1a2eca5c2518553c4179e6f967b6 Mon Sep 17 00:00:00 2001 From: Ben Bracamonte Date: Sat, 30 May 2026 18:38:24 -0500 Subject: [PATCH 2/2] fix(dashboard,listing): resolve @presswork/listing from dist so Next can bundle resumePublish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-listing publish button imported `@presswork/listing`, whose exports map pointed at raw TypeScript source. Next's webpack resolver then failed on the package's NodeNext ".js" import specifiers ("Can't resolve './poller.js'"), and a global extensionAlias workaround broke Next's own server chunk loading ("Cannot find module './901.js'"). Fix mirrors the proven @presswork/shared pattern: - @presswork/listing exports now resolve `import` to compiled ./dist/*.js (types still from ./src), so ".js" specifiers map to real emitted files — no bundler resolution hacks. - Add a dedicated ./publish subpath exporting only resumePublish, so the dashboard doesn't bundle the agent poll-loop (poller.js); dashboard imports @presswork/listing/publish. - Dashboard gains predev/prebuild that run `tsc -b ../shared ../listing`, so dist is always present before next dev/build (fresh clones, CI, Vercel). - Revert the extensionAlias next.config change. Regression tests (packages/listing/src/publish.test.ts): resumePublish stays a callable export, the ./publish subpath export points at an existing file, and publish.ts stays decoupled from the poller barrel. Verified: `next build` green (/listings + /api/listings/[id]/etsy-images build), listing 136 + dashboard 155 tests pass, typecheck + lint clean. --- packages/dashboard/lib/actions/listings.ts | 2 +- packages/dashboard/next.config.ts | 3 ++ packages/dashboard/package.json | 3 ++ packages/listing/package.json | 6 ++- packages/listing/src/index.ts | 2 - packages/listing/src/publish.test.ts | 44 ++++++++++++++++++++++ packages/listing/src/publish.ts | 6 +++ 7 files changed, 62 insertions(+), 4 deletions(-) create mode 100644 packages/listing/src/publish.test.ts create mode 100644 packages/listing/src/publish.ts diff --git a/packages/dashboard/lib/actions/listings.ts b/packages/dashboard/lib/actions/listings.ts index 3c26519..8fa1a91 100644 --- a/packages/dashboard/lib/actions/listings.ts +++ b/packages/dashboard/lib/actions/listings.ts @@ -21,7 +21,7 @@ import { renderMockup, DynamicMockupsApiError, } from "@presswork/shared"; -import { resumePublish } from "@presswork/listing"; +import { resumePublish } from "@presswork/listing/publish"; import { serviceClient } from "@/lib/supabase/server"; import { requireOwnerEmail } from "@/lib/auth"; diff --git a/packages/dashboard/next.config.ts b/packages/dashboard/next.config.ts index e339693..e4cac05 100644 --- a/packages/dashboard/next.config.ts +++ b/packages/dashboard/next.config.ts @@ -16,6 +16,9 @@ const config: NextConfig = { // workspace packages; tell Next to transpile them instead of treating them as // prebuilt deps. @presswork/listing exposes resumePublish for per-listing // publish from the dashboard without requiring a full agent run. + // @presswork/shared is consumed as prebuilt dist/*.js; @presswork/listing is + // transpiled from source for its types. Both must be listed so Next treats + // them as first-party rather than external CJS deps. transpilePackages: ["@presswork/shared", "@presswork/listing"], images: { remotePatterns: [ diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 556e995..610c28f 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "scripts": { + "build:deps": "tsc -b ../shared ../listing", + "predev": "npm run build:deps", + "prebuild": "npm run build:deps", "dev": "next dev", "build": "next build", "start": "next start", diff --git a/packages/listing/package.json b/packages/listing/package.json index b2fabf8..6b320e3 100644 --- a/packages/listing/package.json +++ b/packages/listing/package.json @@ -6,8 +6,12 @@ "types": "./src/index.ts", "exports": { ".": { - "import": "./src/index.ts", + "import": "./dist/index.js", "types": "./src/index.ts" + }, + "./publish": { + "import": "./dist/publish.js", + "types": "./src/publish.ts" } }, "scripts": { diff --git a/packages/listing/src/index.ts b/packages/listing/src/index.ts index a77664f..aec325c 100644 --- a/packages/listing/src/index.ts +++ b/packages/listing/src/index.ts @@ -2,8 +2,6 @@ import { getDb, getLogger } from "@presswork/shared"; import { claimNextDesignPackage } from "./poller.js"; import { publishOne, resumePublish } from "./publisher.js"; -export { resumePublish } from "./publisher.js"; - async function fetchTrendBrief(db: ReturnType, trendBriefId: string) { const { data, error } = await db .from("trend_briefs") diff --git a/packages/listing/src/publish.test.ts b/packages/listing/src/publish.test.ts new file mode 100644 index 0000000..ca41809 --- /dev/null +++ b/packages/listing/src/publish.test.ts @@ -0,0 +1,44 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { describe, it, expect } from "vitest"; +import { resumePublish } from "./publish.js"; + +// Regression guard for the dashboard's per-listing "Publish to Etsy" action. +// The dashboard imports `@presswork/listing/publish`; if that subpath export is +// dropped or repointed at a missing file, Next fails at build time with +// "Can't resolve …". These checks fail fast in unit tests instead. +const here = dirname(fileURLToPath(import.meta.url)); + +describe("@presswork/listing/publish entrypoint", () => { + it("re-exports resumePublish as a callable", () => { + expect(typeof resumePublish).toBe("function"); + }); + + it("exposes the ./publish subpath export pointing at an existing file", () => { + const pkg = JSON.parse( + readFileSync(resolve(here, "..", "package.json"), "utf8") + ) as { exports?: Record }; + + const sub = pkg.exports?.["./publish"]; + expect(sub?.import).toBeDefined(); + + // The referenced source file must exist (relative to the package root). + const target = resolve(here, "..", sub!.import!); + expect(() => readFileSync(target, "utf8")).not.toThrow(); + }); + + it("does not route the dashboard through the agent poll-loop barrel", () => { + // publish.ts must stay decoupled from index.ts/poller.js so importers don't + // pull the agent run-loop into their bundle. Check import statements only, + // not comment prose that may mention these names. + const src = readFileSync(resolve(here, "publish.ts"), "utf8"); + const importLines = src + .split("\n") + .filter((line) => /^\s*(import|export)\b.*\bfrom\b/.test(line)); + for (const line of importLines) { + expect(line).not.toMatch(/["'][^"']*poller/); + expect(line).not.toMatch(/["']\.\/index/); + } + }); +}); diff --git a/packages/listing/src/publish.ts b/packages/listing/src/publish.ts new file mode 100644 index 0000000..f7e4da3 --- /dev/null +++ b/packages/listing/src/publish.ts @@ -0,0 +1,6 @@ +// Library entrypoint for consumers that only need to publish a single listing +// (e.g. the dashboard's per-listing "Publish to Etsy" action). Kept separate +// from index.ts so importers don't pull in the agent poll-loop (poller.js) and +// its dependency cone. resumePublish drives the full Etsy publish path +// (compliance gates, draft create, inventory, image upload, activate). +export { resumePublish } from "./publisher.js";