From 2acd0cdc06b8ff345d4ecb0127100852b9536388 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sat, 18 Apr 2026 23:59:18 +0200 Subject: [PATCH] feat(admin): queue pagination + per-row Show similar button Queue endpoint now returns {data, total, limit, offset}. Admin UI reads ?page + ?limit from URL, renders prev/next + page-size selector + "Showing X-Y of Z" label. Per-row Similar button fetches the MR 5 /queue/:id/similar endpoint via a new SvelteKit proxy route and renders matches inline with score/name/city/date. Essential for navigating the 1000+ row queue after MR 5's crawl fixes. --- backend/internal/domain/discovery/handler.go | 9 +- .../domain/discovery/mock_repo_test.go | 7 + .../internal/domain/discovery/repository.go | 8 + backend/internal/domain/discovery/service.go | 15 +- .../internal/domain/discovery/service_test.go | 18 ++ .../routes/admin/discovery/+page.server.ts | 32 +++- web/src/routes/admin/discovery/+page.svelte | 163 +++++++++++++++++- .../discovery/queue/[id]/similar/+server.ts | 12 ++ 8 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 web/src/routes/admin/discovery/queue/[id]/similar/+server.ts diff --git a/backend/internal/domain/discovery/handler.go b/backend/internal/domain/discovery/handler.go index e6caff1..8c95212 100644 --- a/backend/internal/domain/discovery/handler.go +++ b/backend/internal/domain/discovery/handler.go @@ -174,13 +174,18 @@ func (h *Handler) ListQueue(c *gin.Context) { if offset < 0 { offset = 0 } - rows, err := h.service.ListPendingQueue(c.Request.Context(), limit, offset) + rows, total, err := h.service.ListPendingQueuePaged(c.Request.Context(), limit, offset) if err != nil { apiErr := apierror.Internal("list queue failed") c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) return } - c.JSON(http.StatusOK, gin.H{"data": rows}) + c.JSON(http.StatusOK, gin.H{ + "data": rows, + "total": total, + "limit": limit, + "offset": offset, + }) } func (h *Handler) Accept(c *gin.Context) { diff --git a/backend/internal/domain/discovery/mock_repo_test.go b/backend/internal/domain/discovery/mock_repo_test.go index f1c4e4e..ce7206e 100644 --- a/backend/internal/domain/discovery/mock_repo_test.go +++ b/backend/internal/domain/discovery/mock_repo_test.go @@ -17,6 +17,7 @@ type mockRepo struct { insertDiscFn func(ctx context.Context, d DiscoveredMarket) (uuid.UUID, error) isRejectedFn func(ctx context.Context, nameNormalized, stadt string, year int) (bool, error) queuePendingFn func(ctx context.Context, nameNormalized, stadt string, startDatum *time.Time) (bool, error) + countQueueFn func(ctx context.Context, status string) (int, error) getDiscoveredFn func(ctx context.Context, id uuid.UUID) (DiscoveredMarket, error) beginTxFn func(ctx context.Context) (pgx.Tx, error) markAcceptedFn func(ctx context.Context, tx pgx.Tx, id, eid, r uuid.UUID) error @@ -46,6 +47,12 @@ func (m *mockRepo) QueueHasPending(ctx context.Context, n, s string, sd *time.Ti func (m *mockRepo) ListQueue(ctx context.Context, status string, l, o int) ([]DiscoveredMarket, error) { return nil, nil } +func (m *mockRepo) CountQueue(ctx context.Context, status string) (int, error) { + if m.countQueueFn != nil { + return m.countQueueFn(ctx, status) + } + return 0, nil +} func (m *mockRepo) GetDiscovered(ctx context.Context, id uuid.UUID) (DiscoveredMarket, error) { if m.getDiscoveredFn != nil { return m.getDiscoveredFn(ctx, id) diff --git a/backend/internal/domain/discovery/repository.go b/backend/internal/domain/discovery/repository.go index 3b05ffb..30f5d4a 100644 --- a/backend/internal/domain/discovery/repository.go +++ b/backend/internal/domain/discovery/repository.go @@ -18,6 +18,7 @@ type Repository interface { IsRejected(ctx context.Context, nameNormalized, stadt string, year int) (bool, error) QueueHasPending(ctx context.Context, nameNormalized, stadt string, startDatum *time.Time) (bool, error) ListQueue(ctx context.Context, status string, limit, offset int) ([]DiscoveredMarket, error) + CountQueue(ctx context.Context, status string) (int, error) GetDiscovered(ctx context.Context, id uuid.UUID) (DiscoveredMarket, error) MarkAccepted(ctx context.Context, tx pgx.Tx, id, editionID, reviewer uuid.UUID) error MarkRejected(ctx context.Context, tx pgx.Tx, id uuid.UUID, reviewer uuid.UUID) error @@ -140,6 +141,13 @@ LIMIT $2 OFFSET $3`, status, limit, offset) return out, rows.Err() } +func (r *pgRepository) CountQueue(ctx context.Context, status string) (int, error) { + var n int + err := r.pool.QueryRow(ctx, + `SELECT count(*) FROM discovered_markets WHERE status = $1`, status).Scan(&n) + return n, err +} + func (r *pgRepository) GetDiscovered(ctx context.Context, id uuid.UUID) (DiscoveredMarket, error) { var d DiscoveredMarket err := r.pool.QueryRow(ctx, ` diff --git a/backend/internal/domain/discovery/service.go b/backend/internal/domain/discovery/service.go index 27b3e58..a543ebc 100644 --- a/backend/internal/domain/discovery/service.go +++ b/backend/internal/domain/discovery/service.go @@ -384,9 +384,18 @@ func landToISO(land string) string { return "" } -// ListPendingQueue exposes queue listing for the handler layer. -func (s *Service) ListPendingQueue(ctx context.Context, limit, offset int) ([]DiscoveredMarket, error) { - return s.repo.ListQueue(ctx, StatusPending, limit, offset) +// ListPendingQueuePaged returns pending queue rows with the total count so +// the admin UI can paginate. limit/offset are passed through to the repo. +func (s *Service) ListPendingQueuePaged(ctx context.Context, limit, offset int) ([]DiscoveredMarket, int, error) { + rows, err := s.repo.ListQueue(ctx, StatusPending, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("list queue: %w", err) + } + total, err := s.repo.CountQueue(ctx, StatusPending) + if err != nil { + return nil, 0, fmt.Errorf("count queue: %w", err) + } + return rows, total, nil } // UpdatePending patches editable fields on a pending queue entry. When MarktName diff --git a/backend/internal/domain/discovery/service_test.go b/backend/internal/domain/discovery/service_test.go index 752489e..511e4fb 100644 --- a/backend/internal/domain/discovery/service_test.go +++ b/backend/internal/domain/discovery/service_test.go @@ -322,6 +322,24 @@ func TestServiceCrawlDetachesInsertContextFromRequestCtx(t *testing.T) { } } +func TestListPendingQueuePaged_ReturnsBothRowsAndTotal(t *testing.T) { + // mockRepo.ListQueue returns nil,nil by default (sufficient to verify total flows through). + m := &mockRepo{ + countQueueFn: func(_ context.Context, _ string) (int, error) { return 42, nil }, + } + svc := NewService(m, nil, noopLinkVerifier{}, noopMarketCreator{}) + rows, total, err := svc.ListPendingQueuePaged(context.Background(), 50, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if total != 42 { + t.Errorf("total = %d; want 42", total) + } + if rows != nil { + t.Errorf("rows = %v; want nil (mock returns nil by default)", rows) + } +} + func TestServiceCrawlMultiSourceHighKonfidenz(t *testing.T) { repo := newMockRepo() start := mustParseDate(t, "2026-05-01") diff --git a/web/src/routes/admin/discovery/+page.server.ts b/web/src/routes/admin/discovery/+page.server.ts index c0b7464..f0a5e09 100644 --- a/web/src/routes/admin/discovery/+page.server.ts +++ b/web/src/routes/admin/discovery/+page.server.ts @@ -36,17 +36,35 @@ type Stats = { recent_errors: BucketError[] | null; }; +const VALID_LIMITS = [25, 50, 100, 200] as const; +type ValidLimit = (typeof VALID_LIMITS)[number]; + +function parseLimit(raw: string | null): ValidLimit { + const n = Number(raw ?? 50); + return (VALID_LIMITS as readonly number[]).includes(n) ? (n as ValidLimit) : 50; +} + +type QueueListBody = { + data: DiscoveredMarket[]; + total: number; + limit: number; + offset: number; +}; + export const load: PageServerLoad = async ({ cookies, url }) => { - const limit = Number(url.searchParams.get('limit') ?? 50); - const offset = Number(url.searchParams.get('offset') ?? 0); + const limit = parseLimit(url.searchParams.get('limit')); + const page = Math.max(1, Number(url.searchParams.get('page') ?? 1)); + const offset = (page - 1) * limit; + const [queueRes, statsRes] = await Promise.all([ - serverFetch( - `/admin/discovery/queue?limit=${limit}&offset=${offset}`, - cookies - ), + serverFetch(`/admin/discovery/queue?limit=${limit}&offset=${offset}`, cookies), serverFetch(`/admin/discovery/stats`, cookies) ]); - return { queue: queueRes.data, stats: statsRes.data, limit, offset }; + // serverFetch casts the full JSON body as ApiResponse; the actual response + // shape is { data: [...], total: N, limit: N, offset: N } so queueRes.data + // is the QueueListBody with all fields. + const body = queueRes.data; + return { queue: body.data, total: body.total, stats: statsRes.data, limit, offset, page }; }; export const actions: Actions = { diff --git a/web/src/routes/admin/discovery/+page.svelte b/web/src/routes/admin/discovery/+page.svelte index 5cf141a..e773867 100644 --- a/web/src/routes/admin/discovery/+page.svelte +++ b/web/src/routes/admin/discovery/+page.svelte @@ -1,6 +1,7 @@