From 5f96daf7f3c186705f00ca4db2cc25c2dd19f83e Mon Sep 17 00:00:00 2001 From: vikingowl Date: Tue, 28 Apr 2026 13:43:22 +0200 Subject: [PATCH] feat(market): admin edit link and public feedback form --- backend/internal/domain/market/feedback.go | 128 +++++++++++ .../domain/market/feedback_handler.go | 59 +++++ .../internal/domain/market/feedback_repo.go | 29 +++ .../internal/domain/market/feedback_test.go | 145 ++++++++++++ backend/internal/domain/market/repository.go | 3 + backend/internal/domain/market/routes.go | 3 +- backend/internal/server/routes.go | 8 +- .../000032_market_feedback.down.sql | 1 + .../migrations/000032_market_feedback.up.sql | 15 ++ .../market/MarketFeedbackDialog.svelte | 206 ++++++++++++++++++ web/src/routes/markt/[slug]/+page.server.ts | 58 ++++- web/src/routes/markt/[slug]/+page.svelte | 61 +++++- 12 files changed, 708 insertions(+), 8 deletions(-) create mode 100644 backend/internal/domain/market/feedback.go create mode 100644 backend/internal/domain/market/feedback_handler.go create mode 100644 backend/internal/domain/market/feedback_repo.go create mode 100644 backend/internal/domain/market/feedback_test.go create mode 100644 backend/migrations/000032_market_feedback.down.sql create mode 100644 backend/migrations/000032_market_feedback.up.sql create mode 100644 web/src/lib/components/market/MarketFeedbackDialog.svelte diff --git a/backend/internal/domain/market/feedback.go b/backend/internal/domain/market/feedback.go new file mode 100644 index 0000000..91e0f39 --- /dev/null +++ b/backend/internal/domain/market/feedback.go @@ -0,0 +1,128 @@ +package market + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "marktvogt.de/backend/internal/pkg/email" +) + +const ( + FeedbackCategoryIncorrect = "incorrect_data" + FeedbackCategoryMissing = "missing_data" + FeedbackCategoryDuplicate = "duplicate" + FeedbackCategoryOther = "other" + + FeedbackStatusNew = "new" + FeedbackStatusTriaged = "triaged" + FeedbackStatusResolved = "resolved" + FeedbackStatusSpam = "spam" +) + +var validFeedbackCategories = map[string]bool{ + FeedbackCategoryIncorrect: true, + FeedbackCategoryMissing: true, + FeedbackCategoryDuplicate: true, + FeedbackCategoryOther: true, +} + +type Feedback struct { + ID uuid.UUID + MarketID *uuid.UUID + MarketSlug string + Category string + Email string + Message string + DuplicateURL string + Status string + RemoteIP string + CreatedAt time.Time +} + +type SubmitFeedbackRequest struct { + Category string `json:"category" validate:"required,oneof=incorrect_data missing_data duplicate other"` + Email string `json:"email" validate:"required,email"` + Message string `json:"message" validate:"required,min=10,max=5000"` + DuplicateURL string `json:"duplicate_url" validate:"omitempty,url,max=500"` + TurnstileToken string `json:"turnstile_token" validate:"required"` +} + +type SubmitFeedbackResponse struct { + Data SubmitFeedbackResponseData `json:"data"` +} + +type SubmitFeedbackResponseData struct { + Message string `json:"message"` +} + +var ErrFeedbackDuplicateURLRequired = errors.New("duplicate_url required for duplicate category") + +func (s *Service) SubmitFeedback(ctx context.Context, slug string, req SubmitFeedbackRequest, remoteIP string) error { + if !validFeedbackCategories[req.Category] { + return fmt.Errorf("invalid category %q", req.Category) + } + if req.Category == FeedbackCategoryDuplicate && req.DuplicateURL == "" { + return ErrFeedbackDuplicateURLRequired + } + + if err := s.turnstile.Verify(ctx, req.TurnstileToken, remoteIP); err != nil { + return fmt.Errorf("turnstile: %w", err) + } + + m, err := s.repo.GetBySlug(ctx, slug) + if err != nil { + return fmt.Errorf("looking up market for feedback: %w", err) + } + + fb := Feedback{ + MarketID: &m.ID, + MarketSlug: slug, + Category: req.Category, + Email: req.Email, + Message: req.Message, + DuplicateURL: req.DuplicateURL, + Status: FeedbackStatusNew, + RemoteIP: remoteIP, + } + + if _, err := s.repo.CreateFeedback(ctx, fb); err != nil { + return fmt.Errorf("creating feedback: %w", err) + } + + if s.email != nil && s.adminEmail != "" { + go s.sendFeedbackNotification(m, slug, req) + } + + return nil +} + +func (s *Service) sendFeedbackNotification(m Market, slug string, req SubmitFeedbackRequest) { + subject := fmt.Sprintf("Markt-Feedback (%s): %s", req.Category, m.Name) + body := fmt.Sprintf( + "Neues Feedback zum Markt:\n\nName: %s\nSlug: %s\nKategorie: %s\nE-Mail: %s\n%sNachricht:\n%s\n\nMarkt im Admin-Panel: %s/admin/maerkte/%s/bearbeiten", + m.Name, slug, req.Category, req.Email, + duplicateURLLine(req), + req.Message, + s.frontendURL, m.ID, + ) + + if err := s.email.Send(context.Background(), email.Message{ + To: s.adminEmail, + Subject: subject, + Body: body, + }); err != nil { + slog.Error("failed to send feedback notification", "error", err) + } +} + +func duplicateURLLine(req SubmitFeedbackRequest) string { + if req.Category == FeedbackCategoryDuplicate && req.DuplicateURL != "" { + return fmt.Sprintf("Duplikat-URL: %s\n", req.DuplicateURL) + } + return "" +} diff --git a/backend/internal/domain/market/feedback_handler.go b/backend/internal/domain/market/feedback_handler.go new file mode 100644 index 0000000..4ca82b0 --- /dev/null +++ b/backend/internal/domain/market/feedback_handler.go @@ -0,0 +1,59 @@ +package market + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" + + "marktvogt.de/backend/internal/pkg/apierror" + "marktvogt.de/backend/internal/pkg/turnstile" + "marktvogt.de/backend/internal/pkg/validate" +) + +type FeedbackHandler struct { + service *Service +} + +func NewFeedbackHandler(service *Service) *FeedbackHandler { + return &FeedbackHandler{service: service} +} + +func (h *FeedbackHandler) Submit(c *gin.Context) { + slug := c.Param("slug") + if slug == "" { + apiErr := apierror.BadRequest("invalid_slug", "missing market slug") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + var req SubmitFeedbackRequest + if apiErr := validate.BindJSON(c, &req); apiErr != nil { + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + err := h.service.SubmitFeedback(c.Request.Context(), slug, req, c.ClientIP()) + switch { + case err == nil: + c.JSON(http.StatusAccepted, SubmitFeedbackResponse{ + Data: SubmitFeedbackResponseData{ + Message: "Vielen Dank! Dein Feedback wurde uebermittelt.", + }, + }) + case errors.Is(err, ErrFeedbackDuplicateURLRequired): + apiErr := apierror.BadRequest("duplicate_url_required", "Bitte gib die URL des Duplikats an.") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + case errors.Is(err, turnstile.ErrVerificationFailed): + apiErr := apierror.BadRequest("turnstile_failed", "spam protection verification failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + case errors.Is(err, ErrMarketNotFound): + apiErr := apierror.NotFound("market") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + default: + slog.ErrorContext(c.Request.Context(), "submit feedback failed", "error", err, "slug", slug) + apiErr := apierror.Internal("failed to submit feedback") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + } +} diff --git a/backend/internal/domain/market/feedback_repo.go b/backend/internal/domain/market/feedback_repo.go new file mode 100644 index 0000000..8aaaa24 --- /dev/null +++ b/backend/internal/domain/market/feedback_repo.go @@ -0,0 +1,29 @@ +package market + +import ( + "context" + "fmt" +) + +func (r *pgRepository) CreateFeedback(ctx context.Context, fb Feedback) (Feedback, error) { + const query = ` + INSERT INTO market_feedback ( + market_id, market_slug, category, email, message, + duplicate_url, status, remote_ip + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, created_at + ` + + if fb.Status == "" { + fb.Status = FeedbackStatusNew + } + + if err := r.db.QueryRow(ctx, query, + fb.MarketID, fb.MarketSlug, fb.Category, fb.Email, fb.Message, + fb.DuplicateURL, fb.Status, fb.RemoteIP, + ).Scan(&fb.ID, &fb.CreatedAt); err != nil { + return Feedback{}, fmt.Errorf("inserting feedback: %w", err) + } + + return fb, nil +} diff --git a/backend/internal/domain/market/feedback_test.go b/backend/internal/domain/market/feedback_test.go new file mode 100644 index 0000000..063016f --- /dev/null +++ b/backend/internal/domain/market/feedback_test.go @@ -0,0 +1,145 @@ +package market + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" +) + +type stubTurnstile struct { + err error +} + +func (s stubTurnstile) Verify(_ context.Context, _, _ string) error { return s.err } + +// stubFeedbackRepo implements just enough of Repository for SubmitFeedback tests. +// Everything else panics, since SubmitFeedback doesn't call those paths. +type stubFeedbackRepo struct { + Repository + getBySlug func(ctx context.Context, slug string) (Market, error) + createFeedback func(ctx context.Context, fb Feedback) (Feedback, error) + createdCalls int + lastCreated Feedback +} + +func (s *stubFeedbackRepo) GetBySlug(ctx context.Context, slug string) (Market, error) { + return s.getBySlug(ctx, slug) +} + +func (s *stubFeedbackRepo) CreateFeedback(ctx context.Context, fb Feedback) (Feedback, error) { + s.createdCalls++ + s.lastCreated = fb + if s.createFeedback != nil { + return s.createFeedback(ctx, fb) + } + fb.ID = uuid.New() + return fb, nil +} + +func newFeedbackService(t *testing.T, repo Repository, ts stubTurnstile) *Service { + t.Helper() + return &Service{ + repo: repo, + turnstile: ts, + frontendURL: "https://example.test", + } +} + +func TestSubmitFeedback_RejectsInvalidCategory(t *testing.T) { + svc := newFeedbackService(t, &stubFeedbackRepo{}, stubTurnstile{}) + + err := svc.SubmitFeedback(context.Background(), "some-slug", SubmitFeedbackRequest{ + Category: "spam_us", + Email: "user@example.com", + Message: "valid message text", + TurnstileToken: "tok", + }, "1.2.3.4") + + if err == nil { + t.Fatal("expected error for invalid category, got nil") + } +} + +func TestSubmitFeedback_DuplicateRequiresURL(t *testing.T) { + svc := newFeedbackService(t, &stubFeedbackRepo{}, stubTurnstile{}) + + err := svc.SubmitFeedback(context.Background(), "some-slug", SubmitFeedbackRequest{ + Category: FeedbackCategoryDuplicate, + Email: "user@example.com", + Message: "valid message text", + TurnstileToken: "tok", + }, "1.2.3.4") + + if !errors.Is(err, ErrFeedbackDuplicateURLRequired) { + t.Fatalf("expected ErrFeedbackDuplicateURLRequired, got %v", err) + } +} + +func TestSubmitFeedback_TurnstileFailureSkipsRepo(t *testing.T) { + wantErr := errors.New("turnstile rejected") + repo := &stubFeedbackRepo{ + getBySlug: func(_ context.Context, _ string) (Market, error) { + t.Fatal("repo should not be called when turnstile fails") + return Market{}, nil + }, + } + svc := newFeedbackService(t, repo, stubTurnstile{err: wantErr}) + + err := svc.SubmitFeedback(context.Background(), "some-slug", SubmitFeedbackRequest{ + Category: FeedbackCategoryOther, + Email: "user@example.com", + Message: "valid message text", + TurnstileToken: "tok", + }, "1.2.3.4") + + if err == nil || !errors.Is(err, wantErr) { + t.Fatalf("expected wrapped turnstile error, got %v", err) + } + if repo.createdCalls != 0 { + t.Fatalf("expected 0 CreateFeedback calls, got %d", repo.createdCalls) + } +} + +func TestSubmitFeedback_PersistsWithMarketID(t *testing.T) { + marketID := uuid.New() + repo := &stubFeedbackRepo{ + getBySlug: func(_ context.Context, slug string) (Market, error) { + if slug != "ronneburg-2026" { + t.Fatalf("unexpected slug: %s", slug) + } + return Market{ID: marketID, Name: "Ronneburg 2026"}, nil + }, + } + svc := newFeedbackService(t, repo, stubTurnstile{}) + + err := svc.SubmitFeedback(context.Background(), "ronneburg-2026", SubmitFeedbackRequest{ + Category: FeedbackCategoryDuplicate, + Email: "user@example.com", + Message: "Same as the other Ronneburg market", + DuplicateURL: "https://marktvogt.de/markt/ronneburg-2026-old", + TurnstileToken: "tok", + }, "1.2.3.4") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if repo.createdCalls != 1 { + t.Fatalf("expected 1 CreateFeedback call, got %d", repo.createdCalls) + } + if repo.lastCreated.MarketID == nil || *repo.lastCreated.MarketID != marketID { + t.Fatalf("expected market_id %s, got %v", marketID, repo.lastCreated.MarketID) + } + if repo.lastCreated.MarketSlug != "ronneburg-2026" { + t.Fatalf("expected slug ronneburg-2026, got %q", repo.lastCreated.MarketSlug) + } + if repo.lastCreated.DuplicateURL != "https://marktvogt.de/markt/ronneburg-2026-old" { + t.Fatalf("expected duplicate URL preserved, got %q", repo.lastCreated.DuplicateURL) + } + if repo.lastCreated.Status != FeedbackStatusNew { + t.Fatalf("expected status %q, got %q", FeedbackStatusNew, repo.lastCreated.Status) + } + if repo.lastCreated.RemoteIP != "1.2.3.4" { + t.Fatalf("expected remote IP recorded, got %q", repo.lastCreated.RemoteIP) + } +} diff --git a/backend/internal/domain/market/repository.go b/backend/internal/domain/market/repository.go index a6e5fde..6bd30da 100644 --- a/backend/internal/domain/market/repository.go +++ b/backend/internal/domain/market/repository.go @@ -41,6 +41,9 @@ type Repository interface { SlugExists(ctx context.Context, slug string) (bool, error) FindSimilar(ctx context.Context, id uuid.UUID, name, city string, startDate, endDate time.Time) ([]DuplicateCandidate, error) + // Feedback + CreateFeedback(ctx context.Context, fb Feedback) (Feedback, error) + // Merge audit log InsertMergeLog(ctx context.Context, entry MergeLogEntry) error diff --git a/backend/internal/domain/market/routes.go b/backend/internal/domain/market/routes.go index 131ebb3..94687ab 100644 --- a/backend/internal/domain/market/routes.go +++ b/backend/internal/domain/market/routes.go @@ -2,12 +2,13 @@ package market import "github.com/gin-gonic/gin" -func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, geoH *GeocodeHandler, submitLimit, geocodeLimit gin.HandlerFunc) { +func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, geoH *GeocodeHandler, fbH *FeedbackHandler, submitLimit, geocodeLimit, feedbackLimit gin.HandlerFunc) { markets := rg.Group("/markets") { markets.GET("", h.Search) markets.GET("/:slug", h.GetBySlug) markets.POST("/submit", submitLimit, subH.Submit) + markets.POST("/:slug/feedback", feedbackLimit, fbH.Submit) } rg.POST("/geocode", geocodeLimit, geoH.Geocode) diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go index 7a68e57..abfd6ce 100644 --- a/backend/internal/server/routes.go +++ b/backend/internal/server/routes.go @@ -83,10 +83,12 @@ func (s *Server) registerRoutes() { marketSvc := market.NewServiceFull(marketRepo, emailSender, tsVerifier, geocoder, s.cfg.Notification.AdminEmail, s.cfg.Notification.FrontendURL) marketHandler := market.NewHandler(marketSvc) submissionHandler := market.NewSubmissionHandler(marketSvc) + feedbackHandler := market.NewFeedbackHandler(marketSvc) geocodeHandler := market.NewGeocodeHandler(geocoder) - submitLimit := middleware.RateLimit(3.0/3600.0, 3) // 3 per hour per IP - geocodeLimit := middleware.RateLimit(10.0/60.0, 10) // 10 per minute per IP - market.RegisterRoutes(v1, marketHandler, submissionHandler, geocodeHandler, submitLimit, geocodeLimit) + submitLimit := middleware.RateLimit(3.0/3600.0, 3) // 3 per hour per IP + geocodeLimit := middleware.RateLimit(10.0/60.0, 10) // 10 per minute per IP + feedbackLimit := middleware.RateLimit(5.0/3600.0, 5) // 5 per hour per IP + market.RegisterRoutes(v1, marketHandler, submissionHandler, geocodeHandler, feedbackHandler, submitLimit, geocodeLimit, feedbackLimit) // AI settings store + usage repo — used by AI provider and settings handler encKey, err := apicrypto.DeriveKey([]byte(s.cfg.JWT.Secret)) diff --git a/backend/migrations/000032_market_feedback.down.sql b/backend/migrations/000032_market_feedback.down.sql new file mode 100644 index 0000000..18c877e --- /dev/null +++ b/backend/migrations/000032_market_feedback.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS market_feedback; diff --git a/backend/migrations/000032_market_feedback.up.sql b/backend/migrations/000032_market_feedback.up.sql new file mode 100644 index 0000000..32b9501 --- /dev/null +++ b/backend/migrations/000032_market_feedback.up.sql @@ -0,0 +1,15 @@ +CREATE TABLE market_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + market_id UUID REFERENCES market_editions(id) ON DELETE SET NULL, + market_slug TEXT NOT NULL, + category TEXT NOT NULL CHECK (category IN ('incorrect_data', 'missing_data', 'duplicate', 'other')), + email TEXT NOT NULL, + message TEXT NOT NULL, + duplicate_url TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'new' CHECK (status IN ('new', 'triaged', 'resolved', 'spam')), + remote_ip TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX market_feedback_market_idx ON market_feedback (market_id, created_at DESC); +CREATE INDEX market_feedback_status_idx ON market_feedback (status, created_at DESC); diff --git a/web/src/lib/components/market/MarketFeedbackDialog.svelte b/web/src/lib/components/market/MarketFeedbackDialog.svelte new file mode 100644 index 0000000..e21f17a --- /dev/null +++ b/web/src/lib/components/market/MarketFeedbackDialog.svelte @@ -0,0 +1,206 @@ + + + + {#if turnstileSiteKey} + + {/if} + + + +
+

+ Feedback zu „{marketName}" +

+

+ Hilf uns, die Daten korrekt und vollstaendig zu halten. +

+
+ + {#if form?.feedback?.success} +
+ Danke! Dein Feedback wurde uebermittelt und wird geprueft. +
+ +
+
+ {:else} +
{ + loading = true; + return async ({ update }) => { + loading = false; + await update(); + }; + }} + > + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+
+ + +
+ + {#if category === 'duplicate'} +
+ + +
+ {/if} + +
+ + +
+ +
+ + +
+ +
+

Hinweis zum Datenschutz

+

+ Mit dem Absenden uebermittelst du: deine E-Mail-Adresse, Kategorie, Nachricht + {#if category === 'duplicate'}und die angegebene Duplikat-URL{/if}, sowie ID und Slug + dieses Marktes. Die E-Mail wird nur fuer Rueckfragen verwendet. Details siehe + Datenschutzerklaerung. +

+
+ + {#if turnstileSiteKey} +
+ {/if} +
+ +
+ + +
+
+ {/if} +
+ + diff --git a/web/src/routes/markt/[slug]/+page.server.ts b/web/src/routes/markt/[slug]/+page.server.ts index 31d3d34..d65b86c 100644 --- a/web/src/routes/markt/[slug]/+page.server.ts +++ b/web/src/routes/markt/[slug]/+page.server.ts @@ -1,7 +1,8 @@ -import type { PageServerLoad } from './$types.js'; +import type { Actions, PageServerLoad } from './$types.js'; import { apiFetch, ApiClientError } from '$lib/api/client.js'; import type { MarketDetail, EditionBrief } from '$lib/api/types.js'; -import { error } from '@sveltejs/kit'; +import { env } from '$env/dynamic/public'; +import { error, fail } from '@sveltejs/kit'; export const load: PageServerLoad = async ({ params, url, fetch }) => { const year = url.searchParams.get('year') ?? ''; @@ -11,7 +12,8 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => { const res = await apiFetch(`/markets/${params.slug}${query}`, { fetch }); return { market: res.data, - editions: (res as unknown as { editions?: EditionBrief[] }).editions ?? [] + editions: (res as unknown as { editions?: EditionBrief[] }).editions ?? [], + turnstileSiteKey: env.PUBLIC_TURNSTILE_SITE_KEY ?? '' }; } catch (e) { if (e instanceof ApiClientError && e.status === 404) { @@ -20,3 +22,53 @@ export const load: PageServerLoad = async ({ params, url, fetch }) => { error(500, { message: 'Fehler beim Laden des Marktes.' }); } }; + +const FEEDBACK_CATEGORIES = ['incorrect_data', 'missing_data', 'duplicate', 'other'] as const; + +export const actions: Actions = { + feedback: async ({ params, request, fetch }) => { + const form = await request.formData(); + const category = (form.get('category')?.toString() ?? '').trim(); + const email = (form.get('email')?.toString() ?? '').trim(); + const message = (form.get('message')?.toString() ?? '').trim(); + const duplicateUrl = (form.get('duplicate_url')?.toString() ?? '').trim(); + const turnstileToken = form.get('cf-turnstile-response')?.toString() ?? ''; + + const formState = { category, email, message, duplicateUrl }; + + if (!FEEDBACK_CATEGORIES.includes(category as (typeof FEEDBACK_CATEGORIES)[number])) { + return fail(400, { error: 'Bitte waehle eine Kategorie.', ...formState }); + } + if (!email || !message) { + return fail(400, { error: 'Bitte fuelle alle Pflichtfelder aus.', ...formState }); + } + if (message.length < 10) { + return fail(400, { error: 'Die Nachricht ist zu kurz (min. 10 Zeichen).', ...formState }); + } + if (category === 'duplicate' && !duplicateUrl) { + return fail(400, { error: 'Bitte gib die URL des Duplikats an.', ...formState }); + } + if (!turnstileToken) { + return fail(400, { error: 'Bitte bestaetige die Spam-Pruefung.', ...formState }); + } + + try { + await apiFetch(`/markets/${params.slug}/feedback`, { + method: 'POST', + body: JSON.stringify({ + category, + email, + message, + duplicate_url: duplicateUrl, + turnstile_token: turnstileToken + }), + fetch + }); + + return { feedback: { success: true } }; + } catch (err) { + const msg = err instanceof Error ? err.message : 'Unbekannter Fehler.'; + return fail(500, { error: msg, ...formState }); + } + } +}; diff --git a/web/src/routes/markt/[slug]/+page.svelte b/web/src/routes/markt/[slug]/+page.svelte index 3640d59..3e59ab3 100644 --- a/web/src/routes/markt/[slug]/+page.svelte +++ b/web/src/routes/markt/[slug]/+page.svelte @@ -1,5 +1,6 @@