feat(discovery): edit pending entries + surface quellen links

Expanding any row in the discovery queue now reveals:
- Quellen as clickable URLs (was just a count)
- Hinweis if the agent emitted one
- Inline edit form for markt_name, stadt, bundesland, start/end date,
  and website — the fields the Pass 0 agent gets wrong most often

Backend:
- PATCH /admin/discovery/queue/:id applies a partial update to pending
  entries via a COALESCE-based SQL update. Only fields that were set
  are written.
- Service recomputes name_normalized when markt_name or stadt change so
  dedup stays consistent after edits.
- Status check ensures only 'pending' entries are mutable.

Web:
- Row state $expandedId holds at most one open drawer at a time.
- Dates round-trip through <input type="date"> using the shared
  dateInputValue helper; form action converts back to RFC3339 for Go.
- Existing Accept/Reject buttons untouched — workflow is edit-then-accept.
This commit is contained in:
2026-04-18 09:33:14 +02:00
parent a44005b694
commit bf72095348
8 changed files with 295 additions and 0 deletions

View File

@@ -82,6 +82,28 @@ func (h *Handler) Accept(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"series_id": seriesID, "edition_id": editionID}})
}
func (h *Handler) Update(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_id", "invalid queue id")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req UpdatePendingFields
if err := c.ShouldBindJSON(&req); err != nil {
apiErr := apierror.BadRequest("invalid_body", err.Error())
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if err := h.service.UpdatePending(c.Request.Context(), id, req); err != nil {
slog.WarnContext(c.Request.Context(), "update pending failed", "queue_id", id, "error", err)
apiErr := apierror.Internal("update failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.Status(http.StatusNoContent)
}
type rejectRequest struct {
Reason string `json:"reason" validate:"max=2000"`
}

View File

@@ -83,3 +83,6 @@ func (m *mockRepo) InsertRejection(ctx context.Context, tx pgx.Tx, r RejectedDis
func (m *mockRepo) Stats(ctx context.Context, forwardMonths, recentErrorsLimit int) (Stats, error) {
return Stats{}, nil
}
func (m *mockRepo) UpdatePending(ctx context.Context, id uuid.UUID, f UpdatePendingFields, nn *string) error {
return nil
}

View File

@@ -78,6 +78,19 @@ type Pass0Market struct {
Hinweis string `json:"hinweis"`
}
// UpdatePendingFields is a partial update for a pending discovered_market row.
// Only non-nil fields are written. Name normalization is recomputed when
// MarktName or Stadt is set so dedup stays honest.
type UpdatePendingFields struct {
MarktName *string `json:"markt_name"`
Stadt *string `json:"stadt"`
Bundesland *string `json:"bundesland"`
StartDatum *time.Time `json:"start_datum"`
EndDatum *time.Time `json:"end_datum"`
Website *string `json:"website"`
Hinweis *string `json:"hinweis"`
}
// Status constants for discovered_markets.
const (
StatusPending = "pending"

View File

@@ -26,6 +26,7 @@ type Repository interface {
InsertRejection(ctx context.Context, tx pgx.Tx, r RejectedDiscovery) error
BeginTx(ctx context.Context) (pgx.Tx, error)
Stats(ctx context.Context, forwardMonths, recentErrorsLimit int) (Stats, error)
UpdatePending(ctx context.Context, id uuid.UUID, fields UpdatePendingFields, nameNormalized *string) error
}
// SeriesCandidate is a minimal projection used for name-normalization comparison in Go.
@@ -216,6 +217,30 @@ func (r *pgRepository) BeginTx(ctx context.Context) (pgx.Tx, error) {
return r.pool.BeginTx(ctx, pgx.TxOptions{})
}
func (r *pgRepository) UpdatePending(ctx context.Context, id uuid.UUID, f UpdatePendingFields, nameNormalized *string) error {
// COALESCE-based partial update: unset fields keep their current value.
// Only applies to rows still in 'pending' status.
tag, err := r.pool.Exec(ctx, `
UPDATE discovered_markets
SET markt_name = COALESCE($2, markt_name),
stadt = COALESCE($3, stadt),
bundesland = COALESCE($4, bundesland),
start_datum = COALESCE($5, start_datum),
end_datum = COALESCE($6, end_datum),
website = COALESCE($7, website),
hinweis = COALESCE($8, hinweis),
name_normalized = COALESCE($9, name_normalized)
WHERE id = $1 AND status = 'pending'`,
id, f.MarktName, f.Stadt, f.Bundesland, f.StartDatum, f.EndDatum, f.Website, f.Hinweis, nameNormalized)
if err != nil {
return fmt.Errorf("update pending: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("no pending discovery found with id %s", id)
}
return nil
}
func (r *pgRepository) Stats(ctx context.Context, forwardMonths, recentErrorsLimit int) (Stats, error) {
var s Stats
if err := r.pool.QueryRow(ctx, `SELECT max(last_queried_at) FROM discovery_buckets`).Scan(&s.LastTickAt); err != nil {

View File

@@ -17,6 +17,7 @@ func RegisterRoutes(
{
admin.GET("/stats", h.Stats)
admin.GET("/queue", h.ListQueue)
admin.PATCH("/queue/:id", h.Update)
admin.POST("/queue/:id/accept", h.Accept)
admin.POST("/queue/:id/reject", h.Reject)
}

View File

@@ -333,6 +333,29 @@ func (s *Service) ListPendingQueue(ctx context.Context, limit, offset int) ([]Di
return s.repo.ListQueue(ctx, StatusPending, limit, offset)
}
// UpdatePending patches editable fields on a pending queue entry. When MarktName
// or Stadt changes, name_normalized is recomputed so dedup stays consistent.
func (s *Service) UpdatePending(ctx context.Context, id uuid.UUID, fields UpdatePendingFields) error {
// Need current row to recompute normalized name when either component changes.
current, err := s.repo.GetDiscovered(ctx, id)
if err != nil {
return fmt.Errorf("load pending: %w", err)
}
if current.Status != StatusPending {
return fmt.Errorf("queue entry is %s, cannot update", current.Status)
}
var nameNorm *string
if fields.MarktName != nil || fields.Stadt != nil {
name := current.MarktName
if fields.MarktName != nil {
name = *fields.MarktName
}
n := NormalizeName(name)
nameNorm = &n
}
return s.repo.UpdatePending(ctx, id, fields, nameNorm)
}
// Stats returns a health snapshot for the admin dashboard.
// Shows up to 5 most-recent error buckets inline.
func (s *Service) Stats(ctx context.Context) (Stats, error) {

View File

@@ -91,5 +91,50 @@ export const actions: Actions = {
const message = err instanceof Error ? err.message : 'Reject fehlgeschlagen.';
return fail(500, { error: message });
}
},
update: async ({ request, cookies, fetch }) => {
const form = await request.formData();
const id = String(form.get('id') ?? '');
if (!id) return fail(400, { error: 'missing id' });
// Only send fields the user actually changed. Empty strings are treated as
// explicit clears for text fields; empty dates as null.
const str = (k: string): string | null => {
const v = form.get(k);
return v === null ? null : String(v);
};
const dateToIso = (v: string | null): string | null => {
if (!v) return null;
// 'YYYY-MM-DD' from <input type="date"> → 'YYYY-MM-DDT00:00:00Z'
return `${v}T00:00:00Z`;
};
const body: Record<string, unknown> = {};
const markt_name = str('markt_name');
if (markt_name !== null) body.markt_name = markt_name;
const stadt = str('stadt');
if (stadt !== null) body.stadt = stadt;
const bundesland = str('bundesland');
if (bundesland !== null) body.bundesland = bundesland;
const website = str('website');
if (website !== null) body.website = website;
const hinweis = str('hinweis');
if (hinweis !== null) body.hinweis = hinweis;
const start = str('start_datum');
if (start !== null && start !== '') body.start_datum = dateToIso(start);
const end = str('end_datum');
if (end !== null && end !== '') body.end_datum = dateToIso(end);
try {
await serverFetch(`/admin/discovery/queue/${id}`, cookies, {
method: 'PATCH',
body: JSON.stringify(body),
fetch
});
return { success: true, updatedId: id };
} catch (err) {
const message = err instanceof Error ? err.message : 'Update fehlgeschlagen.';
return fail(500, { error: message });
}
}
};

View File

@@ -6,6 +6,17 @@
const queue = $derived(data.queue ?? []);
const recentErrors = $derived(data.stats.recent_errors ?? []);
// One expanded row at a time.
let expandedId: string | null = $state(null);
function toggle(id: string) {
expandedId = expandedId === id ? null : id;
}
// 'YYYY-MM-DDTHH:mm:ssZ' → 'YYYY-MM-DD' for <input type="date">
function dateInputValue(iso: string | null): string {
return iso ? iso.slice(0, 10) : '';
}
// 'YYYY-MM-DDTHH:mm:ssZ' → 'DD.MM.YYYY' (German) for display.
function fmtDate(iso: string | null): string {
if (!iso) return '';
@@ -121,6 +132,7 @@
class="border-b border-stone-200 text-xs tracking-wider text-stone-500 uppercase dark:border-stone-700 dark:text-stone-400"
>
<tr>
<th class="w-6 py-2 pr-2"></th>
<th class="py-2 pr-4 font-medium">Region</th>
<th class="py-2 pr-4 font-medium">Markt</th>
<th class="py-2 pr-4 font-medium">Stadt</th>
@@ -134,6 +146,16 @@
<tbody>
{#each queue as row (row.id)}
<tr class="border-b border-stone-100 align-top dark:border-stone-800">
<td class="py-3 pr-2 text-stone-400">
<button
type="button"
onclick={() => toggle(row.id)}
class="flex h-6 w-6 items-center justify-center rounded text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:text-stone-400 dark:hover:bg-stone-800 dark:hover:text-stone-100"
aria-label={expandedId === row.id ? 'Collapse' : 'Expand'}
>
{expandedId === row.id ? '▾' : '▸'}
</button>
</td>
<td class="py-3 pr-4 whitespace-nowrap text-stone-600 dark:text-stone-400">
{row.bundesland || row.land}
</td>
@@ -193,6 +215,147 @@
</form>
</td>
</tr>
{#if expandedId === row.id}
<tr
class="border-b border-stone-100 bg-stone-50 dark:border-stone-800 dark:bg-stone-900/50"
>
<td></td>
<td colspan="8" class="py-4 pr-4">
<div class="grid gap-4 md:grid-cols-2">
<!-- Left: audit — quellen + hinweis -->
<div>
<div
class="text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
>
Quellen
</div>
{#if row.quellen && row.quellen.length > 0}
<ul class="mt-2 space-y-1 text-xs">
{#each row.quellen as url, i (i)}
<li class="break-all">
<a
href={url}
target="_blank"
rel="noreferrer noopener"
class="text-blue-600 underline hover:text-blue-500 dark:text-blue-400"
>
{url}
</a>
</li>
{/each}
</ul>
{:else}
<p class="mt-2 text-xs text-stone-400"></p>
{/if}
{#if row.hinweis}
<div
class="mt-4 text-xs font-medium tracking-wider text-stone-500 uppercase dark:text-stone-400"
>
Hinweis
</div>
<p class="mt-2 text-xs text-stone-600 dark:text-stone-300">{row.hinweis}</p>
{/if}
</div>
<!-- Right: editable form -->
<form method="POST" action="?/update" use:enhance class="space-y-2 text-xs">
<input type="hidden" name="id" value={row.id} />
<div>
<label
class="block font-medium text-stone-500 dark:text-stone-400"
for="name-{row.id}">Markt</label
>
<input
id="name-{row.id}"
name="markt_name"
type="text"
value={row.markt_name}
class="mt-1 w-full rounded border border-stone-300 bg-white px-2 py-1 text-sm dark:border-stone-700 dark:bg-stone-800 dark:text-stone-100"
/>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label
class="block font-medium text-stone-500 dark:text-stone-400"
for="stadt-{row.id}">Stadt</label
>
<input
id="stadt-{row.id}"
name="stadt"
type="text"
value={row.stadt}
class="mt-1 w-full rounded border border-stone-300 bg-white px-2 py-1 text-sm dark:border-stone-700 dark:bg-stone-800 dark:text-stone-100"
/>
</div>
<div>
<label
class="block font-medium text-stone-500 dark:text-stone-400"
for="bundesland-{row.id}">Bundesland</label
>
<input
id="bundesland-{row.id}"
name="bundesland"
type="text"
value={row.bundesland}
class="mt-1 w-full rounded border border-stone-300 bg-white px-2 py-1 text-sm dark:border-stone-700 dark:bg-stone-800 dark:text-stone-100"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label
class="block font-medium text-stone-500 dark:text-stone-400"
for="start-{row.id}">Start</label
>
<input
id="start-{row.id}"
name="start_datum"
type="date"
value={dateInputValue(row.start_datum)}
class="mt-1 w-full rounded border border-stone-300 bg-white px-2 py-1 text-sm dark:border-stone-700 dark:bg-stone-800 dark:text-stone-100"
/>
</div>
<div>
<label
class="block font-medium text-stone-500 dark:text-stone-400"
for="end-{row.id}">Ende</label
>
<input
id="end-{row.id}"
name="end_datum"
type="date"
value={dateInputValue(row.end_datum)}
class="mt-1 w-full rounded border border-stone-300 bg-white px-2 py-1 text-sm dark:border-stone-700 dark:bg-stone-800 dark:text-stone-100"
/>
</div>
</div>
<div>
<label
class="block font-medium text-stone-500 dark:text-stone-400"
for="website-{row.id}">Website</label
>
<input
id="website-{row.id}"
name="website"
type="url"
value={row.website}
class="mt-1 w-full rounded border border-stone-300 bg-white px-2 py-1 text-sm dark:border-stone-700 dark:bg-stone-800 dark:text-stone-100"
/>
</div>
<div class="pt-2">
<button
type="submit"
class="rounded bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
>
Speichern
</button>
</div>
</form>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>