Merge branch 'feature/discovery-edit-and-sources' into 'main'
feat(discovery): edit pending entries + surface quellen links See merge request vikingowl/marktvogt.de!12
This commit is contained in:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user