From bf7209534837034ecafde874d86eefee21b4cf35 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 18 Apr 2026 09:33:14 +0200 Subject: [PATCH] feat(discovery): edit pending entries + surface quellen links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 using the shared dateInputValue helper; form action converts back to RFC3339 for Go. - Existing Accept/Reject buttons untouched — workflow is edit-then-accept. --- backend/internal/domain/discovery/handler.go | 22 +++ .../domain/discovery/mock_repo_test.go | 3 + backend/internal/domain/discovery/model.go | 13 ++ .../internal/domain/discovery/repository.go | 25 +++ backend/internal/domain/discovery/routes.go | 1 + backend/internal/domain/discovery/service.go | 23 +++ .../routes/admin/discovery/+page.server.ts | 45 +++++ web/src/routes/admin/discovery/+page.svelte | 163 ++++++++++++++++++ 8 files changed, 295 insertions(+) diff --git a/backend/internal/domain/discovery/handler.go b/backend/internal/domain/discovery/handler.go index ecde49a..6e7e8e7 100644 --- a/backend/internal/domain/discovery/handler.go +++ b/backend/internal/domain/discovery/handler.go @@ -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"` } diff --git a/backend/internal/domain/discovery/mock_repo_test.go b/backend/internal/domain/discovery/mock_repo_test.go index 6e1df7b..576fb7d 100644 --- a/backend/internal/domain/discovery/mock_repo_test.go +++ b/backend/internal/domain/discovery/mock_repo_test.go @@ -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 +} diff --git a/backend/internal/domain/discovery/model.go b/backend/internal/domain/discovery/model.go index db8ba2c..6298a5a 100644 --- a/backend/internal/domain/discovery/model.go +++ b/backend/internal/domain/discovery/model.go @@ -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" diff --git a/backend/internal/domain/discovery/repository.go b/backend/internal/domain/discovery/repository.go index 8104bf1..ce835dc 100644 --- a/backend/internal/domain/discovery/repository.go +++ b/backend/internal/domain/discovery/repository.go @@ -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 { diff --git a/backend/internal/domain/discovery/routes.go b/backend/internal/domain/discovery/routes.go index d3fbe91..2a1311b 100644 --- a/backend/internal/domain/discovery/routes.go +++ b/backend/internal/domain/discovery/routes.go @@ -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) } diff --git a/backend/internal/domain/discovery/service.go b/backend/internal/domain/discovery/service.go index da3eed3..3484eda 100644 --- a/backend/internal/domain/discovery/service.go +++ b/backend/internal/domain/discovery/service.go @@ -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) { diff --git a/web/src/routes/admin/discovery/+page.server.ts b/web/src/routes/admin/discovery/+page.server.ts index 3e8db44..7b91055 100644 --- a/web/src/routes/admin/discovery/+page.server.ts +++ b/web/src/routes/admin/discovery/+page.server.ts @@ -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 → 'YYYY-MM-DDT00:00:00Z' + return `${v}T00:00:00Z`; + }; + const body: Record = {}; + 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 }); + } } }; diff --git a/web/src/routes/admin/discovery/+page.svelte b/web/src/routes/admin/discovery/+page.svelte index 2eb9ace..1a51a58 100644 --- a/web/src/routes/admin/discovery/+page.svelte +++ b/web/src/routes/admin/discovery/+page.svelte @@ -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 + 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" > + Region Markt Stadt @@ -134,6 +146,16 @@ {#each queue as row (row.id)} + + + {row.bundesland || row.land} @@ -193,6 +215,147 @@ + {#if expandedId === row.id} + + + +
+ +
+
+ Quellen +
+ {#if row.quellen && row.quellen.length > 0} +
    + {#each row.quellen as url, i (i)} +
  • + + {url} + +
  • + {/each} +
+ {:else} +

+ {/if} + + {#if row.hinweis} +
+ Hinweis +
+

{row.hinweis}

+ {/if} +
+ + +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ + + {/if} {/each}