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:
@@ -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
1
backend/.gitignore
vendored
@@ -6,6 +6,7 @@
|
||||
*.dylib
|
||||
/backend
|
||||
/api
|
||||
/seed
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
@@ -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
76
backend/cmd/admin/main.go
Normal 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)
|
||||
}
|
||||
21
backend/cmd/hashpw/main.go
Normal file
21
backend/cmd/hashpw/main.go
Normal 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))
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
180
backend/internal/domain/market/admin_handler.go
Normal file
180
backend/internal/domain/market/admin_handler.go
Normal 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(¶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"}})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
47
backend/internal/domain/market/submission_handler.go
Normal file
47
backend/internal/domain/market/submission_handler.go
Normal 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.",
|
||||
},
|
||||
})
|
||||
}
|
||||
26
backend/internal/middleware/role.go
Normal file
26
backend/internal/middleware/role.go
Normal 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()
|
||||
}
|
||||
}
|
||||
48
backend/internal/middleware/role_test.go
Normal file
48
backend/internal/middleware/role_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
65
backend/internal/pkg/email/email.go
Normal file
65
backend/internal/pkg/email/email.go
Normal 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)
|
||||
}
|
||||
47
backend/internal/pkg/slug/slug.go
Normal file
47
backend/internal/pkg/slug/slug.go
Normal 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)
|
||||
}
|
||||
}
|
||||
64
backend/internal/pkg/slug/slug_test.go
Normal file
64
backend/internal/pkg/slug/slug_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
87
backend/internal/pkg/turnstile/turnstile.go
Normal file
87
backend/internal/pkg/turnstile/turnstile.go
Normal 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")
|
||||
@@ -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) {
|
||||
|
||||
10
backend/migrations/000007_add_market_status.down.sql
Normal file
10
backend/migrations/000007_add_market_status.down.sql
Normal 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;
|
||||
25
backend/migrations/000007_add_market_status.up.sql
Normal file
25
backend/migrations/000007_add_market_status.up.sql
Normal 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);
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE markets ALTER COLUMN location DROP NOT NULL;
|
||||
Reference in New Issue
Block a user