feat: add admin panel, market submissions, and email notifications

- Admin CRUD endpoints for markets with role-based middleware
- Anonymous market submission with Cloudflare Turnstile verification
- SMTP email notifications on new submissions (LogSender fallback)
- Market status workflow (pending/approved/rejected) with admin notes
- Nullable location column for submissions without coordinates
- CLI tool for promoting users to admin role
- Slug generation package extracted from seed
- Rate limiting on submission endpoint (3/hour per IP)
- Mailpit added to docker-compose for local email testing
This commit is contained in:
2026-02-27 11:03:44 +01:00
parent cd92e84696
commit 580b9d5e3c
25 changed files with 1581 additions and 47 deletions

View File

@@ -47,3 +47,16 @@ OAUTH_REDIRECT_BASE_URL=http://localhost:8080
# Magic Link
MAGIC_LINK_TTL=15m
MAGIC_LINK_BASE_URL=http://localhost:5173/auth/magic-link/verify
# SMTP (empty = log-only in dev)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=noreply@marktvogt.de
# Cloudflare Turnstile
TURNSTILE_SECRET_KEY=
# Notifications
ADMIN_EMAIL=

1
backend/.gitignore vendored
View File

@@ -6,6 +6,7 @@
*.dylib
/backend
/api
/seed
# Test binary
*.test

View File

@@ -55,6 +55,10 @@ fmt:
build:
go build -o api ./cmd/api
# Promote a user to admin by email
promote-admin email:
go run ./cmd/admin promote {{email}}
# Clean build artifacts
clean:
rm -f api coverage.out coverage.html

76
backend/cmd/admin/main.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"marktvogt.de/backend/internal/config"
"marktvogt.de/backend/internal/database"
)
func main() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})))
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, "usage: admin promote <email>")
os.Exit(1)
}
command := os.Args[1]
if command != "promote" {
fmt.Fprintf(os.Stderr, "unknown command: %s\n", command)
os.Exit(1)
}
email := os.Args[2]
if err := run(email); err != nil {
slog.Error("admin command failed", "error", err)
os.Exit(1)
}
}
func run(email string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
pool, err := connectDB(ctx)
if err != nil {
return err
}
defer pool.Close()
tag, err := pool.Exec(ctx, "UPDATE users SET role = 'admin' WHERE email = $1 AND deleted_at IS NULL", email)
if err != nil {
return fmt.Errorf("updating user role: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("no active user found with email %q", email)
}
slog.Info("promoted user to admin", "email", email)
return nil
}
func connectDB(ctx context.Context) (*pgxpool.Pool, error) {
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
pool, err := pgxpool.New(ctx, dbURL)
if err != nil {
return nil, fmt.Errorf("connect via DATABASE_URL: %w", err)
}
return pool, nil
}
cfg, err := config.Load()
if err != nil {
return nil, err
}
return database.NewPostgres(ctx, cfg.DB)
}

View File

@@ -0,0 +1,21 @@
package main
import (
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: hashpw <password>")
os.Exit(1)
}
hash, err := bcrypt.GenerateFromPassword([]byte(os.Args[1]), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(hash))
}

View File

@@ -27,6 +27,17 @@ services:
timeout: 5s
retries: 5
mailpit:
image: axllent/mailpit:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025/livez"]
interval: 5s
timeout: 5s
retries: 5
volumes:
pgdata:
vkdata:

View File

@@ -8,15 +8,18 @@ import (
)
type Config struct {
App AppConfig
DB DBConfig
Valkey ValkeyConfig
JWT JWTConfig
CORS CORSConfig
Rate RateConfig
Sentry SentryConfig
OAuth OAuthConfig
Magic MagicLinkConfig
App AppConfig
DB DBConfig
Valkey ValkeyConfig
JWT JWTConfig
CORS CORSConfig
Rate RateConfig
Sentry SentryConfig
OAuth OAuthConfig
Magic MagicLinkConfig
SMTP SMTPConfig
Turnstile TurnstileConfig
Notification NotificationConfig
}
type AppConfig struct {
@@ -86,6 +89,22 @@ type MagicLinkConfig struct {
BaseURL string
}
type SMTPConfig struct {
Host string
Port int
User string
Password string
From string
}
type TurnstileConfig struct {
SecretKey string
}
type NotificationConfig struct {
AdminEmail string
}
func Load() (*Config, error) {
port, err := envInt("APP_PORT", 8080)
if err != nil {
@@ -137,6 +156,11 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("MAGIC_LINK_TTL: %w", err)
}
smtpPort, err := envInt("SMTP_PORT", 587)
if err != nil {
return nil, fmt.Errorf("SMTP_PORT: %w", err)
}
jwtSecret := envStr("JWT_SECRET", "")
if jwtSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
@@ -201,6 +225,19 @@ func Load() (*Config, error) {
TTL: magicTTL,
BaseURL: envStr("MAGIC_LINK_BASE_URL", "http://localhost:5173/auth/magic-link/verify"),
},
SMTP: SMTPConfig{
Host: envStr("SMTP_HOST", ""),
Port: smtpPort,
User: envStr("SMTP_USER", ""),
Password: envStr("SMTP_PASSWORD", ""),
From: envStr("SMTP_FROM", "noreply@marktvogt.de"),
},
Turnstile: TurnstileConfig{
SecretKey: envStr("TURNSTILE_SECRET_KEY", ""),
},
Notification: NotificationConfig{
AdminEmail: envStr("ADMIN_EMAIL", ""),
},
}, nil
}

View File

@@ -0,0 +1,180 @@
package market
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/validate"
)
type AdminHandler struct {
service *Service
}
func NewAdminHandler(service *Service) *AdminHandler {
return &AdminHandler{service: service}
}
func (h *AdminHandler) List(c *gin.Context) { //nolint:dupl
var params AdminSearchParams
if err := c.ShouldBindQuery(&params); err != nil {
apiErr := apierror.BadRequest("invalid_params", err.Error())
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
params.Defaults()
markets, total, err := h.service.AdminSearch(c.Request.Context(), params)
if err != nil {
apiErr := apierror.Internal("failed to search markets")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
summaries := make([]AdminMarketSummary, 0, len(markets))
for _, m := range markets {
summaries = append(summaries, ToAdminSummary(m))
}
totalPages := total / params.PerPage
if total%params.PerPage != 0 {
totalPages++
}
c.JSON(http.StatusOK, AdminListResponse{
Data: summaries,
Meta: MetaResponse{
Page: params.Page,
PerPage: params.PerPage,
Total: total,
TotalPages: totalPages,
},
})
}
func (h *AdminHandler) GetByID(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_id", "invalid market ID")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
m, err := h.service.GetByID(c.Request.Context(), id)
if err != nil {
if errors.Is(err, ErrMarketNotFound) {
apiErr := apierror.NotFound("market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to get market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, AdminDetailResponse{Data: ToAdminDetail(m)})
}
func (h *AdminHandler) Create(c *gin.Context) {
var req CreateMarketRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
m, err := h.service.Create(c.Request.Context(), req)
if err != nil {
apiErr := apierror.Internal("failed to create market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusCreated, AdminDetailResponse{Data: ToAdminDetail(m)})
}
func (h *AdminHandler) Update(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_id", "invalid market ID")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req UpdateMarketRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
m, err := h.service.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, ErrMarketNotFound) {
apiErr := apierror.NotFound("market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to update market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, AdminDetailResponse{Data: ToAdminDetail(m)})
}
func (h *AdminHandler) Delete(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_id", "invalid market ID")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if err := h.service.Delete(c.Request.Context(), id); err != nil {
if errors.Is(err, ErrMarketNotFound) {
apiErr := apierror.NotFound("market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to delete market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": "market deleted"}})
}
func (h *AdminHandler) UpdateStatus(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
apiErr := apierror.BadRequest("invalid_id", "invalid market ID")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
var req UpdateStatusRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID, _ := c.Get("user_id")
reviewedBy, _ := userID.(uuid.UUID)
if err := h.service.UpdateStatus(c.Request.Context(), id, req.Status, reviewedBy, req.AdminNotes); err != nil {
if errors.Is(err, ErrMarketNotFound) {
apiErr := apierror.NotFound("market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to update market status")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"message": "status updated"}})
}

View File

@@ -69,8 +69,8 @@ type MarketSummary struct {
State string `json:"state"`
Zip string `json:"zip"`
Country string `json:"country"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
ImageURL string `json:"image_url"`
@@ -88,8 +88,8 @@ type MarketDetail struct {
State string `json:"state"`
Zip string `json:"zip"`
Country string `json:"country"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
OpeningHours json.RawMessage `json:"opening_hours"`
@@ -147,3 +147,202 @@ func ToDetail(m Market) MarketDetail {
ImageURL: m.ImageURL,
}
}
// --- Admin DTOs ---
type AdminSearchParams struct {
Status string `form:"status"`
Query string `form:"q"`
Page int `form:"page"`
PerPage int `form:"per_page"`
}
func (p *AdminSearchParams) Defaults() {
if p.Page < 1 {
p.Page = 1
}
if p.PerPage < 1 {
p.PerPage = 20
}
if p.PerPage > 100 {
p.PerPage = 100
}
}
func (p *AdminSearchParams) Offset() int {
return (p.Page - 1) * p.PerPage
}
type AdminMarketSummary struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
City string `json:"city"`
State string `json:"state"`
Status string `json:"status"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
OrganizerName string `json:"organizer_name"`
SubmitterName string `json:"submitter_name"`
CreatedAt string `json:"created_at"`
}
type AdminMarketDetail struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Street string `json:"street"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
Country string `json:"country"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
OpeningHours json.RawMessage `json:"opening_hours"`
AdmissionInfo json.RawMessage `json:"admission_info"`
Website string `json:"website"`
OrganizerName string `json:"organizer_name"`
ImageURL string `json:"image_url"`
Status string `json:"status"`
SubmitterEmail *string `json:"submitter_email,omitempty"`
SubmitterName string `json:"submitter_name"`
AdminNotes string `json:"admin_notes"`
ReviewedAt *string `json:"reviewed_at,omitempty"`
ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type AdminListResponse struct {
Data []AdminMarketSummary `json:"data"`
Meta MetaResponse `json:"meta"`
}
type AdminDetailResponse struct {
Data AdminMarketDetail `json:"data"`
}
func ToAdminSummary(m Market) AdminMarketSummary {
return AdminMarketSummary{
ID: m.ID,
Slug: m.Slug,
Name: m.Name,
City: m.City,
State: m.State,
Status: m.Status,
StartDate: m.StartDate.Format(time.DateOnly),
EndDate: m.EndDate.Format(time.DateOnly),
OrganizerName: m.OrganizerName,
SubmitterName: m.SubmitterName,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
}
}
func ToAdminDetail(m Market) AdminMarketDetail {
d := AdminMarketDetail{
ID: m.ID,
Slug: m.Slug,
Name: m.Name,
Description: m.Description,
Street: m.Street,
City: m.City,
State: m.State,
Zip: m.Zip,
Country: m.Country,
Latitude: m.Latitude,
Longitude: m.Longitude,
StartDate: m.StartDate.Format(time.DateOnly),
EndDate: m.EndDate.Format(time.DateOnly),
OpeningHours: m.OpeningHours,
AdmissionInfo: m.AdmissionInfo,
Website: m.Website,
OrganizerName: m.OrganizerName,
ImageURL: m.ImageURL,
Status: m.Status,
SubmitterEmail: m.SubmitterEmail,
SubmitterName: m.SubmitterName,
AdminNotes: m.AdminNotes,
ReviewedBy: m.ReviewedBy,
CreatedAt: m.CreatedAt.Format(time.RFC3339),
UpdatedAt: m.UpdatedAt.Format(time.RFC3339),
}
if m.ReviewedAt != nil {
s := m.ReviewedAt.Format(time.RFC3339)
d.ReviewedAt = &s
}
return d
}
// --- Request types ---
type CreateMarketRequest struct {
Name string `json:"name" validate:"required,min=3,max=200"`
Description string `json:"description" validate:"max=5000"`
Latitude *float64 `json:"latitude" validate:"omitempty,gte=-90,lte=90"`
Longitude *float64 `json:"longitude" validate:"omitempty,gte=-180,lte=180"`
Street string `json:"street" validate:"max=200"`
City string `json:"city" validate:"required,max=100"`
State string `json:"state" validate:"max=100"`
Zip string `json:"zip" validate:"max=20"`
Country string `json:"country" validate:"required,len=2"`
StartDate string `json:"start_date" validate:"required"`
EndDate string `json:"end_date" validate:"required"`
OpeningHours json.RawMessage `json:"opening_hours"`
AdmissionInfo json.RawMessage `json:"admission_info"`
Website string `json:"website" validate:"max=500"`
OrganizerName string `json:"organizer_name" validate:"max=200"`
ImageURL string `json:"image_url" validate:"max=500"`
}
type UpdateMarketRequest struct {
Name *string `json:"name" validate:"omitempty,min=3,max=200"`
Description *string `json:"description" validate:"omitempty,max=5000"`
Latitude *float64 `json:"latitude" validate:"omitempty,gte=-90,lte=90"`
Longitude *float64 `json:"longitude" validate:"omitempty,gte=-180,lte=180"`
Street *string `json:"street" validate:"omitempty,max=200"`
City *string `json:"city" validate:"omitempty,max=100"`
State *string `json:"state" validate:"omitempty,max=100"`
Zip *string `json:"zip" validate:"omitempty,max=20"`
Country *string `json:"country" validate:"omitempty,len=2"`
StartDate *string `json:"start_date"`
EndDate *string `json:"end_date"`
OpeningHours *json.RawMessage `json:"opening_hours"`
AdmissionInfo *json.RawMessage `json:"admission_info"`
Website *string `json:"website" validate:"omitempty,max=500"`
OrganizerName *string `json:"organizer_name" validate:"omitempty,max=200"`
ImageURL *string `json:"image_url" validate:"omitempty,max=500"`
}
type SubmitMarketRequest struct {
Name string `json:"name" validate:"required,min=3,max=200"`
Description string `json:"description" validate:"max=5000"`
Latitude *float64 `json:"latitude" validate:"omitempty,gte=-90,lte=90"`
Longitude *float64 `json:"longitude" validate:"omitempty,gte=-180,lte=180"`
City string `json:"city" validate:"required,max=100"`
State string `json:"state" validate:"max=100"`
Zip string `json:"zip" validate:"max=20"`
Country string `json:"country" validate:"required,len=2"`
StartDate string `json:"start_date" validate:"required"`
EndDate string `json:"end_date" validate:"required"`
Website string `json:"website" validate:"max=500"`
OrganizerName string `json:"organizer_name" validate:"max=200"`
SubmitterEmail string `json:"submitter_email" validate:"required,email"`
SubmitterName string `json:"submitter_name" validate:"required,min=2,max=100"`
TurnstileToken string `json:"turnstile_token" validate:"required"`
}
type UpdateStatusRequest struct {
Status string `json:"status" validate:"required,oneof=approved rejected"`
AdminNotes string `json:"admin_notes" validate:"max=2000"`
}
type SubmitResponse struct {
Data SubmitResponseData `json:"data"`
}
type SubmitResponseData struct {
Message string `json:"message"`
}

View File

@@ -8,28 +8,34 @@ import (
)
type Market struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Street string `json:"street"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
Country string `json:"country"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
OpeningHours json.RawMessage `json:"opening_hours"`
AdmissionInfo json.RawMessage `json:"admission_info"`
Website string `json:"website"`
OrganizerName string `json:"organizer_name"`
ImageURL string `json:"image_url"`
IsPublished bool `json:"is_published"`
Distance *float64 `json:"distance,omitempty"` // meters, only set in geo queries
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
Latitude *float64 `json:"latitude"`
Longitude *float64 `json:"longitude"`
Street string `json:"street"`
City string `json:"city"`
State string `json:"state"`
Zip string `json:"zip"`
Country string `json:"country"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
OpeningHours json.RawMessage `json:"opening_hours"`
AdmissionInfo json.RawMessage `json:"admission_info"`
Website string `json:"website"`
OrganizerName string `json:"organizer_name"`
ImageURL string `json:"image_url"`
IsPublished bool `json:"is_published"`
Status string `json:"status"`
SubmitterEmail *string `json:"submitter_email,omitempty"`
SubmitterName string `json:"submitter_name"`
AdminNotes string `json:"admin_notes"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"`
Distance *float64 `json:"distance,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type OpeningHoursEntry struct {

View File

@@ -6,7 +6,9 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -14,6 +16,13 @@ import (
type Repository interface {
Search(ctx context.Context, params SearchParams) ([]Market, int, error)
GetBySlug(ctx context.Context, slug string) (Market, error)
Create(ctx context.Context, m Market) (Market, error)
Update(ctx context.Context, id uuid.UUID, m Market) (Market, error)
Delete(ctx context.Context, id uuid.UUID) error
GetByID(ctx context.Context, id uuid.UUID) (Market, error)
AdminSearch(ctx context.Context, params AdminSearchParams) ([]Market, int, error)
UpdateStatus(ctx context.Context, id uuid.UUID, status string, reviewedBy uuid.UUID, adminNotes string) error
SlugExists(ctx context.Context, slug string) (bool, error)
}
type pgRepository struct {
@@ -121,8 +130,8 @@ func (r *pgRepository) Search(ctx context.Context, params SearchParams) ([]Marke
dataQuery := fmt.Sprintf(`
SELECT
m.id, m.slug, m.name, m.description,
ST_Y(m.location::geometry) AS latitude,
ST_X(m.location::geometry) AS longitude,
CASE WHEN m.location IS NOT NULL THEN ST_Y(m.location::geometry) END AS latitude,
CASE WHEN m.location IS NOT NULL THEN ST_X(m.location::geometry) END AS longitude,
m.street, m.city, m.state, m.zip, m.country,
m.start_date, m.end_date,
m.opening_hours, m.admission_info,
@@ -166,8 +175,8 @@ func (r *pgRepository) GetBySlug(ctx context.Context, slug string) (Market, erro
query := `
SELECT
m.id, m.slug, m.name, m.description,
ST_Y(m.location::geometry) AS latitude,
ST_X(m.location::geometry) AS longitude,
CASE WHEN m.location IS NOT NULL THEN ST_Y(m.location::geometry) END AS latitude,
CASE WHEN m.location IS NOT NULL THEN ST_X(m.location::geometry) END AS longitude,
m.street, m.city, m.state, m.zip, m.country,
m.start_date, m.end_date,
m.opening_hours, m.admission_info,
@@ -198,3 +207,319 @@ func (r *pgRepository) GetBySlug(ctx context.Context, slug string) (Market, erro
}
var ErrMarketNotFound = fmt.Errorf("market not found")
type scanner interface {
Scan(dest ...any) error
}
func scanMarketFull(s scanner) (Market, error) {
var m Market
err := s.Scan(
&m.ID, &m.Slug, &m.Name, &m.Description,
&m.Latitude, &m.Longitude,
&m.Street, &m.City, &m.State, &m.Zip, &m.Country,
&m.StartDate, &m.EndDate,
&m.OpeningHours, &m.AdmissionInfo,
&m.Website, &m.OrganizerName, &m.ImageURL,
&m.IsPublished, &m.Status,
&m.SubmitterEmail, &m.SubmitterName, &m.AdminNotes,
&m.ReviewedAt, &m.ReviewedBy,
&m.CreatedAt, &m.UpdatedAt,
)
return m, err
}
const selectFullColumns = `
m.id, m.slug, m.name, m.description,
CASE WHEN m.location IS NOT NULL THEN ST_Y(m.location::geometry) END AS latitude,
CASE WHEN m.location IS NOT NULL THEN ST_X(m.location::geometry) END AS longitude,
m.street, m.city, m.state, m.zip, m.country,
m.start_date, m.end_date,
m.opening_hours, m.admission_info,
m.website, m.organizer_name, m.image_url,
m.is_published, m.status,
m.submitter_email, m.submitter_name, m.admin_notes,
m.reviewed_at, m.reviewed_by,
m.created_at, m.updated_at`
// returningColumns is like selectFullColumns but without table alias (for INSERT/UPDATE RETURNING)
const returningColumns = `
id, slug, name, description,
CASE WHEN location IS NOT NULL THEN ST_Y(location::geometry) END AS latitude,
CASE WHEN location IS NOT NULL THEN ST_X(location::geometry) END AS longitude,
street, city, state, zip, country,
start_date, end_date,
opening_hours, admission_info,
website, organizer_name, image_url,
is_published, status,
submitter_email, submitter_name, admin_notes,
reviewed_at, reviewed_by,
created_at, updated_at`
func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Market, error) {
query := `SELECT ` + selectFullColumns + ` FROM markets m WHERE m.id = $1`
m, err := scanMarketFull(r.db.QueryRow(ctx, query, id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Market{}, ErrMarketNotFound
}
return Market{}, fmt.Errorf("getting market by id: %w", err)
}
return m, nil
}
func (r *pgRepository) Create(ctx context.Context, m Market) (Market, error) {
if m.ID == uuid.Nil {
m.ID = uuid.New()
}
now := time.Now()
if m.OpeningHours == nil {
m.OpeningHours = []byte("[]")
}
if m.AdmissionInfo == nil {
m.AdmissionInfo = []byte("{}")
}
if m.Status == "" {
m.Status = "approved"
}
hasLocation := m.Latitude != nil && m.Longitude != nil
var locationExpr string
var args []any
argIdx := 0
nextArg := func() string {
argIdx++
return "$" + strconv.Itoa(argIdx)
}
// $1..$4: id, slug, name, description
p1, p2, p3, p4 := nextArg(), nextArg(), nextArg(), nextArg()
args = append(args, m.ID, m.Slug, m.Name, m.Description)
// $5(,$6): location
if hasLocation {
lonArg, latArg := nextArg(), nextArg()
locationExpr = fmt.Sprintf("ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography", lonArg, latArg)
args = append(args, *m.Longitude, *m.Latitude)
} else {
locationExpr = "NULL"
}
// remaining fields
p7, p8, p9, p10, p11 := nextArg(), nextArg(), nextArg(), nextArg(), nextArg()
p12, p13, p14, p15 := nextArg(), nextArg(), nextArg(), nextArg()
p16, p17, p18, p19 := nextArg(), nextArg(), nextArg(), nextArg()
p20, p21, p22 := nextArg(), nextArg(), nextArg()
p23, p24 := nextArg(), nextArg()
args = append(args,
m.Street, m.City, m.State, m.Zip, m.Country,
m.StartDate.Format(time.DateOnly), m.EndDate.Format(time.DateOnly),
m.OpeningHours, m.AdmissionInfo,
m.Website, m.OrganizerName, m.ImageURL, m.Status,
m.SubmitterEmail, m.SubmitterName, m.AdminNotes,
now, now,
)
query := fmt.Sprintf(`
INSERT INTO markets (
id, slug, name, description,
location, street, city, state, zip, country,
start_date, end_date, opening_hours, admission_info,
website, organizer_name, image_url, status,
submitter_email, submitter_name, admin_notes,
created_at, updated_at
) VALUES (
%s, %s, %s, %s,
%s,
%s, %s, %s, %s, %s,
%s::date, %s::date, %s::jsonb, %s::jsonb,
%s, %s, %s, %s,
%s, %s, %s,
%s, %s
)
RETURNING `+returningColumns,
p1, p2, p3, p4,
locationExpr,
p7, p8, p9, p10, p11,
p12, p13, p14, p15,
p16, p17, p18, p19,
p20, p21, p22,
p23, p24,
)
created, err := scanMarketFull(r.db.QueryRow(ctx, query, args...))
if err != nil {
return Market{}, fmt.Errorf("creating market: %w", err)
}
return created, nil
}
func (r *pgRepository) Update(ctx context.Context, id uuid.UUID, m Market) (Market, error) {
hasLocation := m.Latitude != nil && m.Longitude != nil
var locationExpr string
args := []any{id, m.Name, m.Description}
argIdx := 3
nextArg := func() string {
argIdx++
return "$" + strconv.Itoa(argIdx)
}
if hasLocation {
lonArg, latArg := nextArg(), nextArg()
locationExpr = fmt.Sprintf("ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography", lonArg, latArg)
args = append(args, *m.Longitude, *m.Latitude)
} else {
locationExpr = "NULL"
}
p6, p7, p8, p9, p10 := nextArg(), nextArg(), nextArg(), nextArg(), nextArg()
p11, p12, p13, p14 := nextArg(), nextArg(), nextArg(), nextArg()
p15, p16, p17 := nextArg(), nextArg(), nextArg()
args = append(args,
m.Street, m.City, m.State, m.Zip, m.Country,
m.StartDate.Format(time.DateOnly), m.EndDate.Format(time.DateOnly),
m.OpeningHours, m.AdmissionInfo,
m.Website, m.OrganizerName, m.ImageURL,
)
query := fmt.Sprintf(`
UPDATE markets SET
name = $2, description = $3,
location = %s,
street = %s, city = %s, state = %s, zip = %s, country = %s,
start_date = %s::date, end_date = %s::date,
opening_hours = %s::jsonb, admission_info = %s::jsonb,
website = %s, organizer_name = %s, image_url = %s,
updated_at = NOW()
WHERE id = $1
RETURNING `+returningColumns,
locationExpr,
p6, p7, p8, p9, p10,
p11, p12, p13, p14,
p15, p16, p17,
)
updated, err := scanMarketFull(r.db.QueryRow(ctx, query, args...))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Market{}, ErrMarketNotFound
}
return Market{}, fmt.Errorf("updating market: %w", err)
}
return updated, nil
}
func (r *pgRepository) Delete(ctx context.Context, id uuid.UUID) error {
tag, err := r.db.Exec(ctx, "DELETE FROM markets WHERE id = $1", id)
if err != nil {
return fmt.Errorf("deleting market: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrMarketNotFound
}
return nil
}
func (r *pgRepository) AdminSearch(ctx context.Context, params AdminSearchParams) ([]Market, int, error) {
var (
conditions []string
args []any
argIdx int
)
nextArg := func() string {
argIdx++
return "$" + strconv.Itoa(argIdx)
}
if params.Status != "" {
arg := nextArg()
conditions = append(conditions, fmt.Sprintf("m.status = %s", arg))
args = append(args, params.Status)
}
if params.Query != "" {
arg := nextArg()
likeArg := nextArg()
conditions = append(conditions, fmt.Sprintf(
"(m.search_vector @@ plainto_tsquery('german_market', %s) OR m.name ILIKE %s OR m.city ILIKE %s)",
arg, likeArg, likeArg,
))
args = append(args, params.Query, "%"+params.Query+"%")
}
where := ""
if len(conditions) > 0 {
where = "WHERE " + strings.Join(conditions, " AND ")
}
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM markets m %s", where)
var total int
if err := r.db.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("counting admin markets: %w", err)
}
limitArg := nextArg()
offsetArg := nextArg()
args = append(args, params.PerPage, params.Offset())
dataQuery := fmt.Sprintf(`
SELECT %s
FROM markets m
%s
ORDER BY m.created_at DESC
LIMIT %s OFFSET %s
`, selectFullColumns, where, limitArg, offsetArg)
rows, err := r.db.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("querying admin markets: %w", err)
}
defer rows.Close()
var markets []Market
for rows.Next() {
m, err := scanMarketFull(rows)
if err != nil {
return nil, 0, fmt.Errorf("scanning admin market: %w", err)
}
markets = append(markets, m)
}
return markets, total, rows.Err()
}
func (r *pgRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status string, reviewedBy uuid.UUID, adminNotes string) error {
tag, err := r.db.Exec(ctx, `
UPDATE markets SET
status = $2,
reviewed_by = $3,
reviewed_at = NOW(),
admin_notes = $4,
updated_at = NOW()
WHERE id = $1`,
id, status, reviewedBy, adminNotes,
)
if err != nil {
return fmt.Errorf("updating market status: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrMarketNotFound
}
return nil
}
func (r *pgRepository) SlugExists(ctx context.Context, slug string) (bool, error) {
var exists bool
err := r.db.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM markets WHERE slug = $1)", slug).Scan(&exists)
if err != nil {
return false, fmt.Errorf("checking slug existence: %w", err)
}
return exists, nil
}

View File

@@ -2,10 +2,23 @@ package market
import "github.com/gin-gonic/gin"
func RegisterRoutes(rg *gin.RouterGroup, h *Handler) {
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, submitLimit gin.HandlerFunc) {
markets := rg.Group("/markets")
{
markets.GET("", h.Search)
markets.GET("/:slug", h.GetBySlug)
markets.POST("/submit", submitLimit, subH.Submit)
}
}
func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, requireAuth, requireAdmin gin.HandlerFunc) {
admin := rg.Group("/admin/markets", requireAuth, requireAdmin)
{
admin.GET("", h.List)
admin.GET("/:id", h.GetByID)
admin.POST("", h.Create)
admin.PUT("/:id", h.Update)
admin.DELETE("/:id", h.Delete)
admin.PATCH("/:id/status", h.UpdateStatus)
}
}

View File

@@ -3,16 +3,37 @@ package market
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"marktvogt.de/backend/internal/pkg/email"
"marktvogt.de/backend/internal/pkg/slug"
"marktvogt.de/backend/internal/pkg/turnstile"
)
type Service struct {
repo Repository
repo Repository
email email.Sender
turnstile turnstile.Verifier
adminEmail string
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func NewServiceFull(repo Repository, emailSender email.Sender, ts turnstile.Verifier, adminEmail string) *Service {
return &Service{
repo: repo,
email: emailSender,
turnstile: ts,
adminEmail: adminEmail,
}
}
func (s *Service) Search(ctx context.Context, params SearchParams) ([]Market, int, error) {
params.Defaults()
return s.repo.Search(ctx, params)
@@ -24,3 +45,193 @@ func (s *Service) GetBySlug(ctx context.Context, slug string) (Market, error) {
}
return s.repo.GetBySlug(ctx, slug)
}
func (s *Service) GetByID(ctx context.Context, id uuid.UUID) (Market, error) {
return s.repo.GetByID(ctx, id)
}
func (s *Service) Create(ctx context.Context, req CreateMarketRequest) (Market, error) {
startDate, err := time.Parse(time.DateOnly, req.StartDate)
if err != nil {
return Market{}, fmt.Errorf("invalid start_date: %w", err)
}
endDate, err := time.Parse(time.DateOnly, req.EndDate)
if err != nil {
return Market{}, fmt.Errorf("invalid end_date: %w", err)
}
base := slug.Generate(req.Name + " " + req.City)
uniqueSlug, err := slug.MakeUnique(ctx, base, s.repo.SlugExists)
if err != nil {
return Market{}, err
}
m := Market{
Slug: uniqueSlug,
Name: req.Name,
Description: req.Description,
Street: req.Street,
City: req.City,
State: req.State,
Zip: req.Zip,
Country: req.Country,
StartDate: startDate,
EndDate: endDate,
OpeningHours: req.OpeningHours,
AdmissionInfo: req.AdmissionInfo,
Website: req.Website,
OrganizerName: req.OrganizerName,
ImageURL: req.ImageURL,
Status: "approved",
}
if req.Latitude != nil && req.Longitude != nil {
m.Latitude = req.Latitude
m.Longitude = req.Longitude
}
return s.repo.Create(ctx, m)
}
func (s *Service) Update(ctx context.Context, id uuid.UUID, req UpdateMarketRequest) (Market, error) {
existing, err := s.repo.GetByID(ctx, id)
if err != nil {
return Market{}, err
}
if req.Name != nil {
existing.Name = *req.Name
}
if req.Description != nil {
existing.Description = *req.Description
}
if req.Latitude != nil {
existing.Latitude = req.Latitude
}
if req.Longitude != nil {
existing.Longitude = req.Longitude
}
if req.Street != nil {
existing.Street = *req.Street
}
if req.City != nil {
existing.City = *req.City
}
if req.State != nil {
existing.State = *req.State
}
if req.Zip != nil {
existing.Zip = *req.Zip
}
if req.Country != nil {
existing.Country = *req.Country
}
if req.StartDate != nil {
t, err := time.Parse(time.DateOnly, *req.StartDate)
if err != nil {
return Market{}, fmt.Errorf("invalid start_date: %w", err)
}
existing.StartDate = t
}
if req.EndDate != nil {
t, err := time.Parse(time.DateOnly, *req.EndDate)
if err != nil {
return Market{}, fmt.Errorf("invalid end_date: %w", err)
}
existing.EndDate = t
}
if req.OpeningHours != nil {
existing.OpeningHours = *req.OpeningHours
}
if req.AdmissionInfo != nil {
existing.AdmissionInfo = *req.AdmissionInfo
}
if req.Website != nil {
existing.Website = *req.Website
}
if req.OrganizerName != nil {
existing.OrganizerName = *req.OrganizerName
}
if req.ImageURL != nil {
existing.ImageURL = *req.ImageURL
}
return s.repo.Update(ctx, id, existing)
}
func (s *Service) Delete(ctx context.Context, id uuid.UUID) error {
return s.repo.Delete(ctx, id)
}
func (s *Service) AdminSearch(ctx context.Context, params AdminSearchParams) ([]Market, int, error) {
params.Defaults()
return s.repo.AdminSearch(ctx, params)
}
func (s *Service) UpdateStatus(ctx context.Context, id uuid.UUID, status string, reviewedBy uuid.UUID, adminNotes string) error {
return s.repo.UpdateStatus(ctx, id, status, reviewedBy, adminNotes)
}
func (s *Service) SubmitMarket(ctx context.Context, req SubmitMarketRequest, remoteIP string) error {
if err := s.turnstile.Verify(ctx, req.TurnstileToken, remoteIP); err != nil {
return fmt.Errorf("turnstile: %w", err)
}
startDate, err := time.Parse(time.DateOnly, req.StartDate)
if err != nil {
return fmt.Errorf("invalid start_date: %w", err)
}
endDate, err := time.Parse(time.DateOnly, req.EndDate)
if err != nil {
return fmt.Errorf("invalid end_date: %w", err)
}
base := slug.Generate(req.Name + " " + req.City)
uniqueSlug, err := slug.MakeUnique(ctx, base, s.repo.SlugExists)
if err != nil {
return err
}
m := Market{
Slug: uniqueSlug,
Name: req.Name,
Description: req.Description,
City: req.City,
State: req.State,
Zip: req.Zip,
Country: req.Country,
StartDate: startDate,
EndDate: endDate,
Website: req.Website,
OrganizerName: req.OrganizerName,
Status: "pending",
SubmitterEmail: &req.SubmitterEmail,
SubmitterName: req.SubmitterName,
}
if req.Latitude != nil && req.Longitude != nil {
m.Latitude = req.Latitude
m.Longitude = req.Longitude
}
if _, err := s.repo.Create(ctx, m); err != nil {
return fmt.Errorf("creating submission: %w", err)
}
if s.email != nil && s.adminEmail != "" {
go func() {
msg := email.Message{
To: s.adminEmail,
Subject: fmt.Sprintf("Neuer Markt eingereicht: %s", req.Name),
Body: fmt.Sprintf(
"Ein neuer Markt wurde eingereicht:\n\nName: %s\nStadt: %s\nZeitraum: %s - %s\nEingereicht von: %s (%s)\n\nBitte im Admin-Panel pruefen.",
req.Name, req.City, req.StartDate, req.EndDate,
req.SubmitterName, req.SubmitterEmail,
),
}
if err := s.email.Send(context.Background(), msg); err != nil {
slog.Error("failed to send submission notification", "error", err)
}
}()
}
return nil
}

View File

@@ -0,0 +1,47 @@
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 SubmissionHandler struct {
service *Service
}
func NewSubmissionHandler(service *Service) *SubmissionHandler {
return &SubmissionHandler{service: service}
}
func (h *SubmissionHandler) Submit(c *gin.Context) {
var req SubmitMarketRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if err := h.service.SubmitMarket(c.Request.Context(), req, c.ClientIP()); err != nil {
if errors.Is(err, turnstile.ErrVerificationFailed) {
apiErr := apierror.BadRequest("turnstile_failed", "spam protection verification failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
slog.ErrorContext(c.Request.Context(), "submit market failed", "error", err)
apiErr := apierror.Internal("failed to submit market")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusAccepted, SubmitResponse{
Data: SubmitResponseData{
Message: "Vielen Dank! Dein Markt wurde eingereicht und wird nach Pruefung veroeffentlicht.",
},
})
}

View File

@@ -0,0 +1,26 @@
package middleware
import (
"github.com/gin-gonic/gin"
"marktvogt.de/backend/internal/pkg/apierror"
)
func RequireRole(roles ...string) gin.HandlerFunc {
allowed := make(map[string]struct{}, len(roles))
for _, r := range roles {
allowed[r] = struct{}{}
}
return func(c *gin.Context) {
role, _ := c.Get("user_role")
roleStr, _ := role.(string)
if _, ok := allowed[roleStr]; !ok {
apiErr := apierror.Forbidden("insufficient permissions")
c.AbortWithStatusJSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.Next()
}
}

View File

@@ -0,0 +1,48 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestRequireRole(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
role string
allowed []string
wantStatus int
}{
{"admin allowed", "admin", []string{"admin"}, http.StatusOK},
{"user denied", "user", []string{"admin"}, http.StatusForbidden},
{"empty role denied", "", []string{"admin"}, http.StatusForbidden},
{"multiple roles allowed", "admin", []string{"admin", "moderator"}, http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
c, r := gin.CreateTestContext(w)
r.Use(func(c *gin.Context) {
c.Set("user_role", tt.role)
c.Next()
})
r.Use(RequireRole(tt.allowed...))
r.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
c.Request = httptest.NewRequest("GET", "/test", nil)
r.ServeHTTP(w, c.Request)
if w.Code != tt.wantStatus {
t.Errorf("got status %d, want %d", w.Code, tt.wantStatus)
}
})
}
}

View File

@@ -0,0 +1,65 @@
package email
import (
"context"
"fmt"
"log/slog"
"net/smtp"
)
type Message struct {
To string
Subject string
Body string
}
type Sender interface {
Send(ctx context.Context, msg Message) error
}
type SMTPSender struct {
host string
port int
user string
pass string
from string
}
func NewSMTPSender(host string, port int, user, pass, from string) *SMTPSender {
return &SMTPSender{host: host, port: port, user: user, pass: pass, from: from}
}
func (s *SMTPSender) Send(_ context.Context, msg Message) error {
addr := fmt.Sprintf("%s:%d", s.host, s.port)
var auth smtp.Auth
if s.user != "" {
auth = smtp.PlainAuth("", s.user, s.pass, s.host)
}
body := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
s.from, msg.To, msg.Subject, msg.Body)
if err := smtp.SendMail(addr, auth, s.from, []string{msg.To}, []byte(body)); err != nil {
return fmt.Errorf("sending email to %s: %w", msg.To, err)
}
return nil
}
type LogSender struct{}
func (l *LogSender) Send(_ context.Context, msg Message) error {
slog.Info("email (dev mode)",
"to", msg.To,
"subject", msg.Subject,
"body_len", len(msg.Body),
)
return nil
}
func New(host string, port int, user, pass, from string) Sender {
if host == "" {
return &LogSender{}
}
return NewSMTPSender(host, port, user, pass, from)
}

View File

@@ -0,0 +1,47 @@
package slug
import (
"context"
"fmt"
"strings"
)
var replacer = strings.NewReplacer(
"ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss",
"Ä", "ae", "Ö", "oe", "Ü", "ue",
"é", "e", "è", "e", "ê", "e",
"á", "a", "à", "a", "â", "a",
)
func Generate(s string) string {
s = replacer.Replace(strings.ToLower(s))
var buf strings.Builder
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') {
buf.WriteRune(c)
} else {
buf.WriteRune('-')
}
}
result := buf.String()
for strings.Contains(result, "--") {
result = strings.ReplaceAll(result, "--", "-")
}
return strings.Trim(result, "-")
}
type ExistsFunc func(ctx context.Context, slug string) (bool, error)
func MakeUnique(ctx context.Context, base string, exists ExistsFunc) (string, error) {
candidate := base
for i := 2; ; i++ {
found, err := exists(ctx, candidate)
if err != nil {
return "", fmt.Errorf("checking slug %q: %w", candidate, err)
}
if !found {
return candidate, nil
}
candidate = fmt.Sprintf("%s-%d", base, i)
}
}

View File

@@ -0,0 +1,64 @@
package slug
import (
"context"
"testing"
)
func TestGenerate(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Ritterturnier München 2026", "ritterturnier-muenchen-2026"},
{"Große Burg-Festspiele", "grosse-burg-festspiele"},
{"", ""},
{" hello world ", "hello-world"},
{"Ärger mit Ölgemälde", "aerger-mit-oelgemaelde"},
{"café résumé", "cafe-resume"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := Generate(tt.input)
if got != tt.want {
t.Errorf("Generate(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestMakeUnique(t *testing.T) {
ctx := context.Background()
t.Run("first attempt available", func(t *testing.T) {
exists := func(_ context.Context, _ string) (bool, error) {
return false, nil
}
got, err := MakeUnique(ctx, "test-slug", exists)
if err != nil {
t.Fatal(err)
}
if got != "test-slug" {
t.Errorf("got %q, want %q", got, "test-slug")
}
})
t.Run("appends suffix on collision", func(t *testing.T) {
calls := 0
exists := func(_ context.Context, s string) (bool, error) {
calls++
return s == "test-slug" || s == "test-slug-2", nil
}
got, err := MakeUnique(ctx, "test-slug", exists)
if err != nil {
t.Fatal(err)
}
if got != "test-slug-3" {
t.Errorf("got %q, want %q", got, "test-slug-3")
}
if calls != 3 {
t.Errorf("expected 3 calls, got %d", calls)
}
})
}

View File

@@ -0,0 +1,87 @@
package turnstile
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
)
type Verifier interface {
Verify(ctx context.Context, token, remoteIP string) error
}
type Client struct {
secret string
httpClient *http.Client
}
func NewClient(secret string) *Client {
return &Client{
secret: secret,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
type verifyResponse struct {
Success bool `json:"success"`
}
func (c *Client) Verify(ctx context.Context, token, remoteIP string) error {
form := url.Values{
"secret": {c.secret},
"response": {token},
"remoteip": {remoteIP},
}
req, err := http.NewRequestWithContext(ctx, "POST",
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("creating turnstile request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("turnstile verification request: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading turnstile response: %w", err)
}
slog.Debug("turnstile response", "status", resp.StatusCode, "body", string(body))
var result verifyResponse
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("decoding turnstile response: %w", err)
}
if !result.Success {
return ErrVerificationFailed
}
return nil
}
type NoopVerifier struct{}
func (n *NoopVerifier) Verify(_ context.Context, _, _ string) error {
return nil
}
func New(secret string) Verifier {
if secret == "" {
return &NoopVerifier{}
}
return NewClient(secret)
}
var ErrVerificationFailed = fmt.Errorf("turnstile verification failed")

View File

@@ -9,6 +9,8 @@ import (
"marktvogt.de/backend/internal/domain/market"
"marktvogt.de/backend/internal/domain/user"
"marktvogt.de/backend/internal/middleware"
"marktvogt.de/backend/internal/pkg/email"
"marktvogt.de/backend/internal/pkg/turnstile"
)
func (s *Server) registerRoutes() {
@@ -17,12 +19,6 @@ func (s *Server) registerRoutes() {
v1 := s.router.Group("/api/v1")
// Market routes (public)
marketRepo := market.NewRepository(s.db)
marketSvc := market.NewService(marketRepo)
marketHandler := market.NewHandler(marketSvc)
market.RegisterRoutes(v1, marketHandler)
// Auth
userRepo := user.NewRepository(s.db)
tokenSvc := auth.NewTokenService(s.cfg.JWT.Secret, s.cfg.JWT.AccessTTL)
@@ -44,6 +40,25 @@ func (s *Server) registerRoutes() {
userSvc := user.NewService(userRepo)
userHandler := user.NewHandler(userSvc)
user.RegisterRoutes(v1, userHandler, requireAuth)
// Market routes (public + submission + admin)
emailSender := email.New(
s.cfg.SMTP.Host, s.cfg.SMTP.Port,
s.cfg.SMTP.User, s.cfg.SMTP.Password, s.cfg.SMTP.From,
)
tsVerifier := turnstile.New(s.cfg.Turnstile.SecretKey)
marketRepo := market.NewRepository(s.db)
marketSvc := market.NewServiceFull(marketRepo, emailSender, tsVerifier, s.cfg.Notification.AdminEmail)
marketHandler := market.NewHandler(marketSvc)
submissionHandler := market.NewSubmissionHandler(marketSvc)
submitLimit := middleware.RateLimit(3.0/3600.0, 3) // 3 per hour per IP
market.RegisterRoutes(v1, marketHandler, submissionHandler, submitLimit)
// Admin market routes
adminMarketHandler := market.NewAdminHandler(marketSvc)
requireAdmin := middleware.RequireRole("admin")
market.RegisterAdminRoutes(v1, adminMarketHandler, requireAuth, requireAdmin)
}
func (s *Server) healthz(c *gin.Context) {

View File

@@ -0,0 +1,10 @@
DROP TRIGGER IF EXISTS trg_markets_sync_published ON markets;
DROP FUNCTION IF EXISTS markets_sync_published();
DROP INDEX IF EXISTS idx_markets_status;
ALTER TABLE markets DROP COLUMN IF EXISTS reviewed_by;
ALTER TABLE markets DROP COLUMN IF EXISTS reviewed_at;
ALTER TABLE markets DROP COLUMN IF EXISTS admin_notes;
ALTER TABLE markets DROP COLUMN IF EXISTS submitter_name;
ALTER TABLE markets DROP COLUMN IF EXISTS submitter_email;
ALTER TABLE markets DROP COLUMN IF EXISTS status;

View File

@@ -0,0 +1,25 @@
-- Add status workflow columns to markets table
ALTER TABLE markets ADD COLUMN status TEXT NOT NULL DEFAULT 'approved'
CHECK (status IN ('pending', 'approved', 'rejected'));
ALTER TABLE markets ADD COLUMN submitter_email TEXT;
ALTER TABLE markets ADD COLUMN submitter_name TEXT NOT NULL DEFAULT '';
ALTER TABLE markets ADD COLUMN admin_notes TEXT NOT NULL DEFAULT '';
ALTER TABLE markets ADD COLUMN reviewed_at TIMESTAMPTZ;
ALTER TABLE markets ADD COLUMN reviewed_by UUID REFERENCES users(id);
-- Keep is_published in sync with status via trigger
CREATE OR REPLACE FUNCTION markets_sync_published() RETURNS TRIGGER AS $$
BEGIN
NEW.is_published := (NEW.status = 'approved');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_markets_sync_published
BEFORE INSERT OR UPDATE OF status ON markets
FOR EACH ROW EXECUTE FUNCTION markets_sync_published();
-- Fix any existing unpublished markets
UPDATE markets SET status = 'rejected' WHERE is_published = FALSE;
CREATE INDEX idx_markets_status ON markets (status);

View File

@@ -0,0 +1,2 @@
UPDATE markets SET location = ST_SetSRID(ST_MakePoint(0, 0), 4326)::geography WHERE location IS NULL;
ALTER TABLE markets ALTER COLUMN location SET NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE markets ALTER COLUMN location DROP NOT NULL;