diff --git a/backend/.env.example b/backend/.env.example index eac1389..9b5c4d5 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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= diff --git a/backend/.gitignore b/backend/.gitignore index 9afcad4..3566f2b 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,6 +6,7 @@ *.dylib /backend /api +/seed # Test binary *.test diff --git a/backend/Justfile b/backend/Justfile index ebd0938..903dc57 100644 --- a/backend/Justfile +++ b/backend/Justfile @@ -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 diff --git a/backend/cmd/admin/main.go b/backend/cmd/admin/main.go new file mode 100644 index 0000000..c7ad18b --- /dev/null +++ b/backend/cmd/admin/main.go @@ -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 ") + 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) +} diff --git a/backend/cmd/hashpw/main.go b/backend/cmd/hashpw/main.go new file mode 100644 index 0000000..609c346 --- /dev/null +++ b/backend/cmd/hashpw/main.go @@ -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 ") + 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)) +} diff --git a/backend/deploy/docker-compose.yml b/backend/deploy/docker-compose.yml index 119bac6..b7f96bd 100644 --- a/backend/deploy/docker-compose.yml +++ b/backend/deploy/docker-compose.yml @@ -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: diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 27dc7c6..7f61941 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -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 } diff --git a/backend/internal/domain/market/admin_handler.go b/backend/internal/domain/market/admin_handler.go new file mode 100644 index 0000000..e53afa8 --- /dev/null +++ b/backend/internal/domain/market/admin_handler.go @@ -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(¶ms); 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"}}) +} diff --git a/backend/internal/domain/market/dto.go b/backend/internal/domain/market/dto.go index d470478..f318323 100644 --- a/backend/internal/domain/market/dto.go +++ b/backend/internal/domain/market/dto.go @@ -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"` +} diff --git a/backend/internal/domain/market/model.go b/backend/internal/domain/market/model.go index 9049f49..2724ad1 100644 --- a/backend/internal/domain/market/model.go +++ b/backend/internal/domain/market/model.go @@ -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 { diff --git a/backend/internal/domain/market/repository.go b/backend/internal/domain/market/repository.go index 6b91a45..ce2e0e9 100644 --- a/backend/internal/domain/market/repository.go +++ b/backend/internal/domain/market/repository.go @@ -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 +} diff --git a/backend/internal/domain/market/routes.go b/backend/internal/domain/market/routes.go index 2c8ecc6..2768efc 100644 --- a/backend/internal/domain/market/routes.go +++ b/backend/internal/domain/market/routes.go @@ -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) } } diff --git a/backend/internal/domain/market/service.go b/backend/internal/domain/market/service.go index 0442dd5..be9d24f 100644 --- a/backend/internal/domain/market/service.go +++ b/backend/internal/domain/market/service.go @@ -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 +} diff --git a/backend/internal/domain/market/submission_handler.go b/backend/internal/domain/market/submission_handler.go new file mode 100644 index 0000000..935fe31 --- /dev/null +++ b/backend/internal/domain/market/submission_handler.go @@ -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.", + }, + }) +} diff --git a/backend/internal/middleware/role.go b/backend/internal/middleware/role.go new file mode 100644 index 0000000..625a362 --- /dev/null +++ b/backend/internal/middleware/role.go @@ -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() + } +} diff --git a/backend/internal/middleware/role_test.go b/backend/internal/middleware/role_test.go new file mode 100644 index 0000000..1088be7 --- /dev/null +++ b/backend/internal/middleware/role_test.go @@ -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) + } + }) + } +} diff --git a/backend/internal/pkg/email/email.go b/backend/internal/pkg/email/email.go new file mode 100644 index 0000000..24a965b --- /dev/null +++ b/backend/internal/pkg/email/email.go @@ -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) +} diff --git a/backend/internal/pkg/slug/slug.go b/backend/internal/pkg/slug/slug.go new file mode 100644 index 0000000..f7b84ba --- /dev/null +++ b/backend/internal/pkg/slug/slug.go @@ -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) + } +} diff --git a/backend/internal/pkg/slug/slug_test.go b/backend/internal/pkg/slug/slug_test.go new file mode 100644 index 0000000..63d4422 --- /dev/null +++ b/backend/internal/pkg/slug/slug_test.go @@ -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) + } + }) +} diff --git a/backend/internal/pkg/turnstile/turnstile.go b/backend/internal/pkg/turnstile/turnstile.go new file mode 100644 index 0000000..b95eae5 --- /dev/null +++ b/backend/internal/pkg/turnstile/turnstile.go @@ -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") diff --git a/backend/internal/server/routes.go b/backend/internal/server/routes.go index 5bb9dba..edb88e1 100644 --- a/backend/internal/server/routes.go +++ b/backend/internal/server/routes.go @@ -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) { diff --git a/backend/migrations/000007_add_market_status.down.sql b/backend/migrations/000007_add_market_status.down.sql new file mode 100644 index 0000000..e7031b5 --- /dev/null +++ b/backend/migrations/000007_add_market_status.down.sql @@ -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; diff --git a/backend/migrations/000007_add_market_status.up.sql b/backend/migrations/000007_add_market_status.up.sql new file mode 100644 index 0000000..8dbe590 --- /dev/null +++ b/backend/migrations/000007_add_market_status.up.sql @@ -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); diff --git a/backend/migrations/000008_nullable_market_location.down.sql b/backend/migrations/000008_nullable_market_location.down.sql new file mode 100644 index 0000000..159626e --- /dev/null +++ b/backend/migrations/000008_nullable_market_location.down.sql @@ -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; diff --git a/backend/migrations/000008_nullable_market_location.up.sql b/backend/migrations/000008_nullable_market_location.up.sql new file mode 100644 index 0000000..623cd74 --- /dev/null +++ b/backend/migrations/000008_nullable_market_location.up.sql @@ -0,0 +1 @@ +ALTER TABLE markets ALTER COLUMN location DROP NOT NULL;