Skip to content

Commit 1b94dea

Browse files
bracclaude
andcommitted
feat(builder,listing): brief archive + fix bp145 publish (provider 39, $25.99 default)
Builder archive: - Migration 056 widens trend_briefs.status CHECK to allow 'archived' — an operator-only parking state for needs_description briefs that leaves the Builder queue and is excluded from the ledger watchdog. - archiveBrief/unarchiveBrief actions + getArchivedBriefs query; Archive button on FromScoutCard; Archived section on the Builder page with Restore + Delete. CLAUDE.md status flow updated. Listing bp145 publish fix (copy generation was never broken — the failures were downstream on the variants blueprint): - Pricing: publisher.ts now falls back to a per-blueprint default Etsy price (listingDefaultPriceUsd, 145 -> $25.99) when neither the listing's price_usd nor brief.price_target_usd is set, instead of $0.00 tripping the floor. - Provider: the forward path already writes provider 39 (SwiftPOD); added Python tests locking that invariant. The 16 historical provider-3 designs are repaired by scripts/repair-bp145-provider.ts (manual, dry-run by default, --apply to commit) which flips provider 3 -> 39 (variant IDs are identical across providers for White S/M/L/XL/2XL) and resets the two stuck listings. Migration 056 is intentionally un-applied and the repair script un-run — both are operator-gated cloud mutations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c09aa84 commit 1b94dea

16 files changed

Lines changed: 1432 additions & 53 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ Full DDL lives in `infra/supabase/migrations/`. Four core tables drive agent han
100100

101101
| Table | Owner | Status flow |
102102
|---|---|---|
103-
| `trend_briefs` | Scout writes, Builder/Design read | `needs_review``needs_description``approved``processing``done` \| `error` |
103+
| `trend_briefs` | Scout writes, Builder/Design read | `needs_review``needs_description``approved``processing``done` \| `error`; operator-only parking state: `needs_description``archived` (Builder page only; excluded from Ledger watchdog) |
104104
| `design_packages` | Design writes, Listing reads | `needs_review``touch_up``needs_review``approved``processing``done` \| `error` |
105105
| `listings` | Listing | `pending``needs_review``pending_publish``publishing``active` \| `error` |
106106
| `orders` | Ledger | `logged` \| `error` (terminal — fulfillment lives outside this codebase) |
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
-- Add 'archived' to the trend_briefs.status CHECK constraint (migration 056).
2+
--
3+
-- Purpose: operator-only parking state for Builder-queue briefs. A brief at
4+
-- 'archived' was at 'needs_description' and the operator chose to defer it
5+
-- rather than continue building. It leaves the active Builder queue and is
6+
-- excluded from the Ledger watchdog's counts. Archived briefs are restorable
7+
-- ('archived' → 'needs_description') and deletable from the dashboard.
8+
--
9+
-- Idempotent: DROP CONSTRAINT IF EXISTS + re-ADD matches the pattern used
10+
-- in migration 034.
11+
12+
ALTER TABLE trend_briefs DROP CONSTRAINT IF EXISTS trend_briefs_status_check;
13+
ALTER TABLE trend_briefs ADD CONSTRAINT trend_briefs_status_check
14+
CHECK (status IN (
15+
'pending',
16+
'needs_review',
17+
'needs_description',
18+
'approved',
19+
'processing',
20+
'done',
21+
'error',
22+
'archived'
23+
));

packages/dashboard/app/builder/page.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@ import { SurfaceCard } from "@/components/ui/SurfaceCard";
22
import { EmptyState } from "@/components/ui/EmptyState";
33
import { FromScoutCard } from "@/components/builder/FromScoutCard";
44
import { ManualEntryForm } from "@/components/builder/ManualEntryForm";
5+
import { ArchivedBriefRow } from "@/components/builder/ArchivedBriefRow";
56
import { RealtimeRefresh } from "@/components/realtime/RealtimeRefresh";
6-
import { getBuilderQueue } from "@/lib/queries/builder";
7+
import { getBuilderQueue, getArchivedBriefs } from "@/lib/queries/builder";
78
import { getRuntimeFlags } from "@/lib/queries/overview";
89
import { deriveDefaultImageModel } from "@/lib/models/image-models";
910

1011
export const revalidate = 30;
1112

1213
export default async function BuilderPage() {
13-
const [queue, flags] = await Promise.all([getBuilderQueue(), getRuntimeFlags()]);
14+
const [queue, archived, flags] = await Promise.all([
15+
getBuilderQueue(),
16+
getArchivedBriefs(),
17+
getRuntimeFlags(),
18+
]);
1419
const defaultImageModel = deriveDefaultImageModel(flags);
1520

1621
return (
@@ -58,6 +63,24 @@ export default async function BuilderPage() {
5863
>
5964
<ManualEntryForm defaultImageModel={defaultImageModel} />
6065
</SurfaceCard>
66+
67+
<SurfaceCard
68+
title={`Archived — ${archived.length}`}
69+
subtitle="Parked briefs. Restore to return one to the active queue, or delete to remove it permanently."
70+
>
71+
{archived.length === 0 ? (
72+
<EmptyState
73+
title="Nothing archived."
74+
hint="Click Archive on any From Scout card to park it here."
75+
/>
76+
) : (
77+
<div className="flex flex-col divide-y divide-(--surface-line)">
78+
{archived.map((b) => (
79+
<ArchivedBriefRow key={b.id} brief={b} />
80+
))}
81+
</div>
82+
)}
83+
</SurfaceCard>
6184
</div>
6285
);
6386
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"use client";
2+
3+
import { useTransition } from "react";
4+
import { Button } from "@/components/ui/Button";
5+
import { ConfirmDelete } from "@/components/ui/ConfirmDelete";
6+
import { unarchiveBrief, archiveBrief } from "@/lib/actions/builder";
7+
import { deleteBrief } from "@/lib/actions/scout";
8+
import type { TrendBriefRow } from "@/lib/queries/types";
9+
10+
interface Props {
11+
brief: TrendBriefRow;
12+
}
13+
14+
/**
15+
* Single row inside the Builder page's Archived section.
16+
*
17+
* Shows niche + age, with two actions:
18+
* - Restore: flips 'archived' → 'needs_description' (back to active queue)
19+
* - Delete: two-step destructive confirm via ConfirmDelete (blocked when designs reference it)
20+
*/
21+
export function ArchivedBriefRow({ brief }: Props) {
22+
const [isRestoring, startRestore] = useTransition();
23+
24+
const created = new Date(brief.created_at).toISOString().slice(0, 10);
25+
26+
return (
27+
<div className="flex flex-wrap items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
28+
<div className="flex min-w-0 flex-col gap-0.5">
29+
<span className="truncate text-sm font-medium text-(--text-primary)">
30+
{brief.niche}
31+
</span>
32+
<span className="font-mono text-[11px] text-(--text-muted)">
33+
{brief.id.slice(0, 8)} · {created}
34+
</span>
35+
</div>
36+
37+
<div className="flex shrink-0 items-center gap-2">
38+
{/* Restore — puts the brief back in the active Builder queue */}
39+
<form
40+
action={(fd) => {
41+
startRestore(async () => {
42+
await unarchiveBrief(fd);
43+
});
44+
}}
45+
>
46+
<input type="hidden" name="id" value={brief.id} />
47+
<Button type="submit" variant="secondary" size="sm" disabled={isRestoring}>
48+
{isRestoring ? "Restoring…" : "Restore"}
49+
</Button>
50+
</form>
51+
52+
{/* Delete — permanent; blocked when design_packages reference this brief */}
53+
<ConfirmDelete
54+
action={deleteBrief}
55+
id={brief.id}
56+
label="Delete"
57+
helper="This cannot be undone. Delete any linked designs first."
58+
/>
59+
</div>
60+
</div>
61+
);
62+
}

packages/dashboard/components/builder/FromScoutCard.tsx

Lines changed: 73 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ImageModelPicker } from "@/components/models/ImageModelPicker";
77
import {
88
buildPromptForBrief,
99
sendToDesign,
10+
archiveBrief,
1011
type BuildResult,
1112
} from "@/lib/actions/builder";
1213
import type { StyleId } from "@/lib/styles/catalog";
@@ -61,6 +62,7 @@ export function FromScoutCard({ brief, defaultImageModel }: Props) {
6162
// (sendToDesign writes image_description on the spawned child brief). On
6263
// page reload, every card returns to collapsed.
6364
const [isExpanded, setIsExpanded] = useState(false);
65+
const [isArchiving, startArchive] = useTransition();
6466

6567
const created = new Date(brief.created_at).toISOString().slice(0, 10);
6668

@@ -133,58 +135,82 @@ export function FromScoutCard({ brief, defaultImageModel }: Props) {
133135

134136
return (
135137
<div className="flex flex-col gap-3 rounded-(--radius-md) border border-(--surface-line) bg-(--surface-1) p-4">
136-
<button
137-
type="button"
138-
onClick={() => setIsExpanded((v) => !v)}
139-
className="flex w-full flex-wrap items-baseline justify-between gap-3 text-left"
140-
aria-expanded={isExpanded}
141-
>
142-
<div className="flex flex-col gap-0.5">
143-
<div className="flex items-center gap-2">
144-
<span aria-hidden className="text-(--text-muted) tabular">
145-
{isExpanded ? "▾" : "▸"}
146-
</span>
147-
<span className="text-sm font-medium text-(--text-primary)">{brief.niche}</span>
148-
{sentCount > 0 && (
149-
<span
150-
className="rounded-(--radius-sm) bg-(--accent-good)/15 px-2 py-0.5 text-[11px] font-medium text-(--accent-good)"
151-
title="Designs sent from this brief in the current session"
152-
>
153-
Sent {sentCount}
154-
</span>
155-
)}
156-
{!isExpanded && brief.color_palette && brief.color_palette.length > 0 && (
157-
<span className="ml-1 flex gap-0.5">
158-
{brief.color_palette.slice(0, 5).map((c) => (
159-
<span
160-
key={c}
161-
title={c}
162-
className="inline-block h-3 w-3 rounded-(--radius-sm) border border-(--surface-line)"
163-
style={{ background: c }}
164-
/>
165-
))}
138+
{/* Row: expand-toggle (left) + archive button (right). Siblings — not nested —
139+
because a <button> cannot contain another interactive element. */}
140+
<div className="flex flex-wrap items-baseline justify-between gap-2">
141+
<button
142+
type="button"
143+
onClick={() => setIsExpanded((v) => !v)}
144+
className="flex min-w-0 flex-1 flex-wrap items-baseline gap-3 text-left"
145+
aria-expanded={isExpanded}
146+
>
147+
<div className="flex flex-col gap-0.5">
148+
<div className="flex items-center gap-2">
149+
<span aria-hidden className="text-(--text-muted) tabular">
150+
{isExpanded ? "▾" : "▸"}
166151
</span>
167-
)}
168-
</div>
169-
<span className="font-mono text-[11px] text-(--text-muted)">
170-
{brief.id.slice(0, 8)} · {created}
171-
</span>
172-
</div>
173-
{brief.style_keywords && brief.style_keywords.length > 0 && (
174-
<div className="flex flex-wrap gap-1">
175-
{brief.style_keywords
176-
.slice(0, isExpanded ? 6 : 3)
177-
.map((kw) => (
152+
<span className="text-sm font-medium text-(--text-primary)">{brief.niche}</span>
153+
{sentCount > 0 && (
178154
<span
179-
key={kw}
180-
className="rounded-(--radius-sm) bg-(--surface-2) px-2 py-0.5 text-[11px] text-(--text-secondary)"
155+
className="rounded-(--radius-sm) bg-(--accent-good)/15 px-2 py-0.5 text-[11px] font-medium text-(--accent-good)"
156+
title="Designs sent from this brief in the current session"
181157
>
182-
{kw}
158+
Sent {sentCount}
159+
</span>
160+
)}
161+
{!isExpanded && brief.color_palette && brief.color_palette.length > 0 && (
162+
<span className="ml-1 flex gap-0.5">
163+
{brief.color_palette.slice(0, 5).map((c) => (
164+
<span
165+
key={c}
166+
title={c}
167+
className="inline-block h-3 w-3 rounded-(--radius-sm) border border-(--surface-line)"
168+
style={{ background: c }}
169+
/>
170+
))}
183171
</span>
184-
))}
172+
)}
173+
</div>
174+
<span className="font-mono text-[11px] text-(--text-muted)">
175+
{brief.id.slice(0, 8)} · {created}
176+
</span>
185177
</div>
186-
)}
187-
</button>
178+
{brief.style_keywords && brief.style_keywords.length > 0 && (
179+
<div className="flex flex-wrap gap-1">
180+
{brief.style_keywords
181+
.slice(0, isExpanded ? 6 : 3)
182+
.map((kw) => (
183+
<span
184+
key={kw}
185+
className="rounded-(--radius-sm) bg-(--surface-2) px-2 py-0.5 text-[11px] text-(--text-secondary)"
186+
>
187+
{kw}
188+
</span>
189+
))}
190+
</div>
191+
)}
192+
</button>
193+
194+
{/* Archive — moves this brief out of the active queue without deleting it */}
195+
<form
196+
action={(fd) => {
197+
startArchive(async () => {
198+
await archiveBrief(fd);
199+
});
200+
}}
201+
>
202+
<input type="hidden" name="id" value={brief.id} />
203+
<Button
204+
type="submit"
205+
variant="ghost"
206+
size="sm"
207+
disabled={isArchiving || isBuilding || isSending}
208+
title="Park this brief without deleting — restore it from the Archived section below"
209+
>
210+
{isArchiving ? "Archiving…" : "Archive"}
211+
</Button>
212+
</form>
213+
</div>
188214

189215
{!isExpanded ? null : (
190216
<>

packages/dashboard/lib/actions/builder.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,60 @@ describe("createManualBrief", () => {
170170
expect(capture.inserts[0].data).toMatchObject({ niche: "original design" });
171171
});
172172
});
173+
174+
describe("archiveBrief", () => {
175+
it("flips status to 'archived' with an optimistic needs_description guard", async () => {
176+
const { mod, capture, revalidatePath } = await loadModule({ trend_briefs: {} });
177+
178+
await mod.archiveBrief(makeFormData({ id: VALID_ID }));
179+
180+
expect(capture.updates).toHaveLength(1);
181+
const u = capture.updates[0];
182+
expect(u.table).toBe("trend_briefs");
183+
expect(u.data).toEqual({ status: "archived", error_message: null });
184+
// Optimistic concurrency filter — must include both id and status guard
185+
expect(u.filters).toContainEqual(["id", VALID_ID]);
186+
expect(u.filters).toContainEqual(["status", "needs_description"]);
187+
188+
expect(revalidatePath).toHaveBeenCalledWith("/builder");
189+
expect(revalidatePath).toHaveBeenCalledWith("/");
190+
});
191+
192+
it("throws when the DB update fails", async () => {
193+
const { mod } = await loadModule({
194+
trend_briefs: { updateError: { message: "conflict" } },
195+
});
196+
197+
await expect(
198+
mod.archiveBrief(makeFormData({ id: VALID_ID })),
199+
).rejects.toThrow(/Archive failed: conflict/);
200+
});
201+
});
202+
203+
describe("unarchiveBrief", () => {
204+
it("flips status back to 'needs_description' with an optimistic archived guard", async () => {
205+
const { mod, capture, revalidatePath } = await loadModule({ trend_briefs: {} });
206+
207+
await mod.unarchiveBrief(makeFormData({ id: VALID_ID }));
208+
209+
expect(capture.updates).toHaveLength(1);
210+
const u = capture.updates[0];
211+
expect(u.table).toBe("trend_briefs");
212+
expect(u.data).toEqual({ status: "needs_description", error_message: null });
213+
expect(u.filters).toContainEqual(["id", VALID_ID]);
214+
expect(u.filters).toContainEqual(["status", "archived"]);
215+
216+
expect(revalidatePath).toHaveBeenCalledWith("/builder");
217+
expect(revalidatePath).toHaveBeenCalledWith("/");
218+
});
219+
220+
it("throws when the DB update fails", async () => {
221+
const { mod } = await loadModule({
222+
trend_briefs: { updateError: { message: "not found" } },
223+
});
224+
225+
await expect(
226+
mod.unarchiveBrief(makeFormData({ id: VALID_ID })),
227+
).rejects.toThrow(/Restore failed: not found/);
228+
});
229+
});

0 commit comments

Comments
 (0)