fix(review): #89 explicit metadata lock — deterministic copy on lock, not on review
The Review screen now has an explicit 'Lock & Build Title' step. Until the
owner locks, title/short_description stay empty (no 'ready' copy from an
unlocked AI draft). On lock the server validates the taxonomy, sets
metadata_locked=1, builds + persists the deterministic title/short_description
(owner prose wins), and returns the ready state.
- schema.sql: add metadata_locked INTEGER DEFAULT 0 to slabs
- db/__init__.py: bump _SCHEMA_SENTINELS for metadata_locked
- slabs.py: expose metadata_locked in GET; reject it in PUT (server-managed)
- review.py: POST /{id}/review action=lock path; regular review reports
stored (empty) copy until locked
- test_review.py: 8 tests covering lock gate, owner-prose-wins, stale taxonomy
- frontend/api/slabs.ts: Slab.metadata_locked, ReviewResult.locked,
ReviewInput.action, lockSlab() helper
- Review.svelte: lockMetadata() with correct commit-token ordering (bump AFTER
inner commit so the inner PUT isn't superseded), Lock button, locked banner
- e2e/m3-create.spec.ts: pare to single golden path (capture→review→lock→
ready→publish→done); mock respects optimistic concurrency (client_rev) so
stale PUTs 409 like the real backend
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import {
|
||||
getSlab, updateSlab, reviewSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
getSlab, updateSlab, reviewSlab, lockSlab, getTaxonomy, getSettings, inferTaxon, inferContent,
|
||||
ApiError,
|
||||
type Slab, type Taxonomy, type TaxCategory, type TaxTag, type SettingsView,
|
||||
} from '../../api/slabs';
|
||||
@@ -105,10 +105,14 @@
|
||||
ready: boolean; missing: string[]; title: string | null;
|
||||
short_description: string | null; price: number | null;
|
||||
price_suggestion: number | null; primary_wood_category_id: number | null;
|
||||
locked: boolean;
|
||||
}
|
||||
let review: ReviewOut | null = null;
|
||||
let reviewBusy = false;
|
||||
let reviewError = '';
|
||||
// #89: explicit lock of the confirmed taxonomy set.
|
||||
let lockBusy = false;
|
||||
let lockError = '';
|
||||
// Monotonic token: each commit captures the current value and ignores its
|
||||
// result if a newer commit has since started. Without this, two concurrent
|
||||
// commits (e.g. a select change racing the SKU blur) resolve out of order and
|
||||
@@ -210,6 +214,46 @@
|
||||
}
|
||||
}
|
||||
|
||||
// #89: explicit lock — persist the current (already-committed) taxonomy, then
|
||||
// have the server build + store the deterministic title/short_description from
|
||||
// that locked set. Owner prose in the title/short-desc fields wins (METADATA-50).
|
||||
// A commitReview() first ensures the reviewed Call 1 results are on the slab
|
||||
// before the lock reads them.
|
||||
async function lockMetadata(): Promise<void> {
|
||||
if (!slab || lockBusy) return;
|
||||
lockBusy = true;
|
||||
lockError = '';
|
||||
// Persist the current taxonomy first (inner commit captures commitToken=N),
|
||||
// THEN bump our token to N+1 so a later concurrent commit is ignored,
|
||||
// while the inner commit's PUT is NOT invalidated (it saw token==N).
|
||||
await commitReview();
|
||||
const myToken = ++commitToken;
|
||||
try {
|
||||
const res = await lockSlab(slabId, {
|
||||
primary_wood_category_id: primaryCatId === '' ? undefined : Number(primaryCatId),
|
||||
title: title || undefined,
|
||||
short_description: shortDescription || undefined,
|
||||
});
|
||||
if (myToken !== commitToken) return;
|
||||
review = res;
|
||||
if (res.primary_wood_category_id != null) primaryCatId = res.primary_wood_category_id;
|
||||
if (res.title) title = res.title;
|
||||
if (res.short_description) shortDescription = res.short_description;
|
||||
slab = await getSlab(slabId);
|
||||
if (myToken !== commitToken) return;
|
||||
} catch (e) {
|
||||
if (myToken !== commitToken) return;
|
||||
lockError = e instanceof ApiError ? e.message : 'Could not lock the metadata. Try again.';
|
||||
} finally {
|
||||
if (myToken === commitToken) lockBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// #89: the lock is available once the pattern inputs exist (species + a wood
|
||||
// category). The button is the unmistakable "Call 1 → review → lock → copy" step.
|
||||
let canLock = false;
|
||||
$: canLock = !!slab?.species_id && (slab?.wood_category_ids?.length ?? 0) > 0;
|
||||
|
||||
async function runInference() {
|
||||
if (!slab) return;
|
||||
aiBusy = true;
|
||||
@@ -425,6 +469,33 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- #89: explicit lock — the unmistakable "Call 1 → review → LOCK → copy" step.
|
||||
Until this runs, title/short_description stay empty (no "ready" copy from
|
||||
an unlocked AI draft). Owner prose already typed into the fields below wins. -->
|
||||
<div class="lockbox" data-testid="lock-section">
|
||||
{#if review?.locked}
|
||||
<p class="locked-note" data-testid="locked-note">
|
||||
Metadata locked — title & short description built and saved.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="lock-hint">
|
||||
{canLock
|
||||
? 'Review the fields above, then lock the metadata to build the title & short description.'
|
||||
: 'Select a species and a wood category, then lock to build the title & short description.'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="lock-btn"
|
||||
data-testid="lock-metadata"
|
||||
disabled={!canLock || lockBusy}
|
||||
on:click={lockMetadata}
|
||||
>
|
||||
{lockBusy ? 'Locking…' : 'Lock metadata & build title'}
|
||||
</button>
|
||||
{#if lockError}<p class="nudge" role="alert">{lockError}</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title / Description -->
|
||||
<div class="field">
|
||||
<label class="lbl" for="title">Title</label>
|
||||
@@ -510,6 +581,11 @@
|
||||
.nudge { color: #e8a; font-size: .74rem; margin: 4px 0 0; }
|
||||
.sug { color: #aaa; font-size: .74rem; font-weight: 400; margin-left: 6px; }
|
||||
.gen { margin-top: 8px; } .gen-btn { padding: 8px 12px; border: 0; border-radius: 3px; background: #00e5ff; color: #002; font-weight: 600; font-size: .82rem; }
|
||||
.lockbox { margin: 14px 0; padding: 12px; border: 1px solid #2a4a2a; border-radius: 3px; background: #12210f; }
|
||||
.lockbox .lock-hint { color: #9fb89f; font-size: .78rem; margin: 0 0 8px; }
|
||||
.lock-btn { width: 100%; padding: 11px; border: 0; border-radius: 3px; background: #37d05f; color: #002; font-weight: 700; font-size: .9rem; }
|
||||
.lock-btn:disabled { background: #2a2a2a; color: #777; }
|
||||
.locked-note { color: #7fd37f; font-size: .82rem; font-weight: 600; margin: 0; }
|
||||
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||||
.status.ok { background: #122a12; color: #7fd37f; }
|
||||
.missing { color: #999; font-size: .76rem; margin: 6px 0 0; }
|
||||
|
||||
Reference in New Issue
Block a user