commit 2a5b661443555a8df1ba8e361c91dbd804f75467 Author: Beyhan Oğur Date: Sun Apr 26 21:46:42 2026 +0300 first commit diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..57a990f --- /dev/null +++ b/.air.toml @@ -0,0 +1,58 @@ +#:schema https://json.schemastore.org/any.json + +env_files = [] +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + entrypoint = ["./tmp/main"] + exclude_dir = ["assets", "tmp", "vendor", "testdata","frontend"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + ignore_dangerous_root_dir = false + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + app_start_timeout = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.env b/.env new file mode 100644 index 0000000..3de06a8 --- /dev/null +++ b/.env @@ -0,0 +1,95 @@ +NEXT_AUTH_SECRET=bFcOqf37V1DgSxsibuZ79jSIaI4MZ9TCB1Y7iWZFJZrtZdUJapesSi0dXo2Bx8xY + +GITHUB_CLIENT_ID='Ov23liUt9B61O46Mdfm4' +GITHUB_CLIENT_SECRET='c7fc8dcb1b2c8f22120608425d07d5efd995baaf' +GITHUB_SCOPE=['user:email'] + +GOOGLE_CLIENT_ID='915364976256-691m0s87as2r5vdbqr96f6humblseobt.apps.googleusercontent.com' +GOOGLE_CLIENT_SECRET='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' +GOOGLE_REDIRECT_URL=http://localhost:8080/v1/auth/google/callback + +AUTH_NO_SECRET=MlBdj44xjhZIlxQIiz4ZuszB1yvRMW0A +BASE_API_URL=http://localhost:8080 +# BASE_API_URL=https://api.beyhano.com.tr +NEXT_PUBLIC_API_URL=http://localhost:8080 +BASE_SITE_URL=http://localhost:3000 +AUTH_NO_ORIGIN=http://localhost:3000 +BASE_SITE_NAME='Beyhan Oğur' +NODE_ENV='development' +NEXT_PORT=3000 +APP_TITLE='Beyhan Oğur' +NEXT_API_SECRET='6YdkEwOZC0j5K9a5vJtJQGIuwAoqGG4c' +# NUXT_PUBLIC_API_BASE=https://api.beyhano.com.tr +CLOUD_FLARE_SITE_KEY='0x4AAAAAACHzHKvlEwMamxCM' +CLOUD_FLARE_SECRET='0x4AAAAAACHzHHisTSFzGw15HvwXF3yXRIg' +# JWT için gizli anahtar. Güvenlik için bunu daha karmaşık bir değerle değiştirin. +JWT_SECRET="go-gin-mTFY2jAOMWWxadVIWjRoPG9aOM3z9srCVoU35Gs1VZaRKgXet26cztUE8LLpwok9" +#####################GO################### +### Db Configuration +DB_URL="gogin:gg7678290@tcp(10.80.80.70:3306)/gogin?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=30s&writeTimeout=30s&multiStatements=true" +########################## +# Redis Configuration +REDIS_HOST=10.80.80.70 +REDIS_PORT=6379 +REDIS_USER=default +REDIS_PASSWORD=gg7678290 +REDIS_URL=redis://default:gg7678290@10.80.80.70:6379/4 +############################# +# Email Settings (Mailpit) +EMAIL_HOST=10.80.80.70 +EMAIL_PORT=1025 +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" +EMAIL_USE_TLS=false +EMAIL_USE_SSL=false +EMAIL_FROM=noreply@gauth.local +############################# +# App Genel Ayarları +PORT=8080 +################################ +# AVATANE IMAGES +AVATAR_H=150 +AVATAR_W=150 +AVATAR_Q=90 +AVATAR_B=cover +AVATAR_F=webp +####################### +# Home IMAGES +HOME_IMAGE_H=400 +HOME_IMAGE_W=400 +HOME_IMAGE_Q=90 +HOME_IMAGE_B=cover +HOME_IMAGE_F=webp +####################### +# Aboutme IMAGES +ABOUTME_IMAGE_H=400 +ABOUTME_IMAGE_W=400 +ABOUTME_IMAGE_Q=90 +ABOUTME_IMAGE_B=cover +ABOUTME_IMAGE_F=webp +####################### +# MyService IMAGES +SERVICE_IMAGE_H=256 +SERVICE_IMAGE_W=256 +SERVICE_IMAGE_Q=90 +SERVICE_IMAGE_B=cover +SERVICE_IMAGE_F=webp +####################### +# BANNER IMAGES +BANNER_IMAGE_H=700 +BANNER_IMAGE_W=1920 +BANNER_IMAGE_Q=85 +BANNER_IMAGE_B=cover +BANNER_IMAGE_F=webp +################################ +################################ +SET_DEBUG=true +CORS_DEBUG=true +APP_ENV=development +VITE_API_BASE_URL=http://localhost:8080 +FRONTEND_URL=http://localhost:3000 +SESSION_SECRET=go-gin-mTFY2jAOMWWxadVIWjRoPG9aOM3z9srCVoU35Gs1VZaRKgXet26cztUE8LLpwok9 +CLIENT_SECRET='2222' +CLIENT_ID='2222' +ACCESS_TOKEN_EXPIRE_MINUTES=180 +REFRESH_TOKEN_EXPIRE_DAYS=60 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..82b72d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +### Go template +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ +tmp/ +frontend/node_modules/ +# Go workspace file +go.work +go.work.sum + + + +# Frontend +frontend/.next/ +frontend/out/ +frontend/build/ +frontend/dist/ +frontend/.DS_Store +.DS_Store +*.log diff --git a/app/controllers/AuthControllers.go b/app/controllers/AuthControllers.go new file mode 100644 index 0000000..37668cb --- /dev/null +++ b/app/controllers/AuthControllers.go @@ -0,0 +1,615 @@ +package controllers + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + database "goGin/app/database/config" + "goGin/app/database/models" + "goGin/app/middlewares" + "goGin/app/services" + configs "goGin/config" + utils "goGin/pkg/utis" + + "github.com/gin-gonic/gin" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + "golang.org/x/oauth2/google" + "gorm.io/gorm" +) + +// AuthResponse +type AuthResponse struct { + User UserResponse `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// RegisterPayload +type RegisterPayload struct { + UserName string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=6"` +} + +// LoginPayload +type LoginPayload struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +// RefreshPayload +type RefreshPayload struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +// Helper to generate secure token for email verification +func generateSecureToken() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} + +// Register godoc +// @Summary Register a new user +// @Description Register a new user. Sends verification email. Does NOT return tokens. +// @Tags auth +// @Accept json +// @Produce json +// @Param register body RegisterPayload true "Register payload" +// @Success 201 {object} controllers.AuthResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/auth/register [post] +func Register(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + + var payload RegisterPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Check existing email + var existing models.User + if err := database.DB.Where("email = ?", payload.Email).First(&existing).Error; err == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "email already registered"}) + return + } + + hashedPwd, err := utils.HashPassword(payload.Password) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + return + } + + // Email Verification Token + verificationToken := generateSecureToken() + emailVerified := false + + user := models.User{ + UserName: payload.UserName, + Email: payload.Email, + Password: hashedPwd, + EmailVerified: &emailVerified, + EmailVerifyToken: verificationToken, + } + + if err := database.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Send Verification Email + go func() { + if err := utils.SendVerificationEmail(user.Email, verificationToken); err != nil { + fmt.Printf("Failed to send verification email to %s: %v\n", user.Email, err) + } + }() + + // Response + c.JSON(http.StatusCreated, gin.H{ + "message": "Registration successful. Please check your email to verify your account.", + "user": toUserResponse(user), + }) +} + +// VerifyEmail godoc +// @Summary Verify email address +// @Description Verify email using token +// @Tags auth +// @Accept json +// @Produce json +// @Param token query string true "Verification Token" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/auth/verify-email [get] +func VerifyEmail(c *gin.Context) { + token := c.Query("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) + return + } + + var user models.User + if err := database.DB.Where("email_verify_token = ?", token).First(&user).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or expired token"}) + return + } + + now := time.Now() + verified := true + user.EmailVerified = &verified + user.EmailVerifiedAt = &now + user.EmailVerifyToken = "" // Clear token + + if err := database.DB.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify email"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Email verified successfully"}) +} + +// Login godoc +// @Summary Login user +// @Description Login with email and password, returns tokens +// @Tags auth +// @Accept json +// @Produce json +// @Param login body LoginPayload true "Login payload" +// @Success 200 {object} controllers.AuthResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/v1/auth/login [post] +func Login(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + + var payload LoginPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := database.DB.Where("email = ?", payload.Email).First(&user).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if !utils.CheckPasswordHash(payload.Password, user.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + + // Check if email is verified + if user.EmailVerified != nil && !*user.EmailVerified { + c.JSON(http.StatusUnauthorized, gin.H{"error": "email not verified"}) + return + } + + isAdmin := false + if user.IsAdmin != nil && *user.IsAdmin { + isAdmin = true + } + + jwtService := services.NewJWTService() + accessToken, err := jwtService.GenerateToken(user.ID, isAdmin) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"}) + return + } + refreshToken, err := jwtService.GenerateRefreshToken(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "user": toUserResponse(user), + "access_token": accessToken, + "refresh_token": refreshToken, + }) +} + +// Refresh godoc +// @Summary Refresh access token +// @Description usage: send refresh token to get new access token and refresh token +// @Tags auth +// @Accept json +// @Produce json +// @Param refresh body RefreshPayload true "Refresh token payload" +// @Success 200 {object} map[string]string "Returns both access_token and refresh_token" +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /api/v1/auth/refresh [post] +func Refresh(c *gin.Context) { + var payload RefreshPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + jwtService := services.NewJWTService() + claims, err := jwtService.ValidateToken(payload.RefreshToken) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"}) + return + } + + if claims.TokenType != "refresh" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not a refresh token"}) + return + } + + // Get User + var userID uint + switch v := claims.UserID.(type) { + case float64: + userID = uint(v) + case uint: + userID = v + default: + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user claim"}) + return + } + + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) + return + } + + isAdmin := false + if user.IsAdmin != nil && *user.IsAdmin { + isAdmin = true + } + + newAccessToken, err := jwtService.GenerateToken(user.ID, isAdmin) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"}) + return + } + + newRefreshToken, err := jwtService.GenerateRefreshToken(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "access_token": newAccessToken, + "refresh_token": newRefreshToken, + }) +} + +// Me godoc +// @Summary Get current user (me) +// @Description Get current authenticated user information +// @Tags auth +// @Security BearerAuth +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/auth/me [get] +func Me(c *gin.Context) { + claims, ok := middlewares.GetAuthClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + var userID uint + switch v := claims.UserID.(type) { + case float64: + userID = uint(v) + case uint: + userID = v + default: + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid user claim"}) + return + } + + var user models.User + if err := database.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + isAdmin := false + if user.IsAdmin != nil && *user.IsAdmin { + isAdmin = true + } + + isVerified := false + if user.EmailVerified != nil && *user.EmailVerified { + isVerified = true + } + + // Frontend'in beklediği formata göre response döndür + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.UserName, + "email": user.Email, + "email_verified": isVerified, + "is_admin": isAdmin, + }) +} + +// OAuth Helpers +var ( + googleOauthConfig = &oauth2.Config{ + RedirectURL: "", // Will be set in init or handler + ClientID: "", + ClientSecret: "", + Scopes: []string{"https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"}, + Endpoint: google.Endpoint, + } + githubOauthConfig = &oauth2.Config{ + RedirectURL: "", + ClientID: "", + ClientSecret: "", + Scopes: []string{"user:email"}, + Endpoint: github.Endpoint, + } +) + +func getGoogleConfig() *oauth2.Config { + googleOauthConfig.ClientID = configs.AppConfig.GoogleClientID + googleOauthConfig.ClientSecret = configs.AppConfig.GoogleClientSecret + googleOauthConfig.RedirectURL = configs.AppConfig.GoogleRedirectURL + return googleOauthConfig +} + +func getGithubConfig() *oauth2.Config { + githubOauthConfig.ClientID = configs.AppConfig.GithubClientID + githubOauthConfig.ClientSecret = configs.AppConfig.GithubClientSecret + githubOauthConfig.RedirectURL = configs.AppConfig.GithubRedirectURL + return githubOauthConfig +} + +// GoogleLogin godoc +// @Summary Google OAuth2 Login +// @Description Redirects to Google for authentication +// @Tags auth +// @Success 302 +// @Router /api/v1/auth/google [get] +func GoogleLogin(c *gin.Context) { + url := getGoogleConfig().AuthCodeURL("state_google", oauth2.AccessTypeOffline) + c.Redirect(http.StatusTemporaryRedirect, url) +} + +// GoogleCallback godoc +// @Summary Google OAuth2 Callback +// @Description Handles Google OAuth2 callback +// @Tags auth +// @Success 200 {object} controllers.AuthResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/auth/google/callback [get] +func GoogleCallback(c *gin.Context) { + code := c.Query("code") + token, err := getGoogleConfig().Exchange(context.Background(), code) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token: " + err.Error()}) + return + } + + client := getGoogleConfig().Client(context.Background(), token) + resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info: " + err.Error()}) + return + } + defer resp.Body.Close() + + userData, err := io.ReadAll(resp.Body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read user info"}) + return + } + + var googleUser struct { + ID string `json:"id"` + Email string `json:"email"` + VerifiedEmail bool `json:"verified_email"` + Name string `json:"name"` + Picture string `json:"picture"` + } + if err := json.Unmarshal(userData, &googleUser); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse user info"}) + return + } + + handleSocialLogin(c, "google", googleUser.ID, googleUser.Email, googleUser.Name, googleUser.Picture) +} + +// GithubLogin godoc +// @Summary GitHub OAuth2 Login +// @Description Redirects to GitHub for authentication +// @Tags auth +// @Success 302 +// @Router /api/v1/auth/github [get] +func GithubLogin(c *gin.Context) { + url := getGithubConfig().AuthCodeURL("state_github", oauth2.AccessTypeOffline) + c.Redirect(http.StatusTemporaryRedirect, url) +} + +// GithubCallback godoc +// @Summary GitHub OAuth2 Callback +// @Description Handles GitHub OAuth2 callback +// @Tags auth +// @Success 200 {object} controllers.AuthResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/auth/github/callback [get] +func GithubCallback(c *gin.Context) { + code := c.Query("code") + token, err := getGithubConfig().Exchange(context.Background(), code) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange token: " + err.Error()}) + return + } + + client := getGithubConfig().Client(context.Background(), token) + resp, err := client.Get("https://api.github.com/user") + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user info: " + err.Error()}) + return + } + defer resp.Body.Close() + + userData, err := io.ReadAll(resp.Body) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read user info"}) + return + } + + var githubUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + } + if err := json.Unmarshal(userData, &githubUser); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to parse user info"}) + return + } + + // GitHub email might be private, need to fetch separately if empty + email := githubUser.Email + if email == "" { + // Fetch emails + emailResp, err := client.Get("https://api.github.com/user/emails") + if err == nil { + defer emailResp.Body.Close() + var emails []struct { + Email string `json:"email"` + Primary bool `json:"primary"` + Verified bool `json:"verified"` + } + if body, err := io.ReadAll(emailResp.Body); err == nil { + json.Unmarshal(body, &emails) + for _, e := range emails { + if e.Primary && e.Verified { + email = e.Email + break + } + } + } + } + } + + if email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Could not retrieve email from GitHub"}) + return + } + + handleSocialLogin(c, "github", fmt.Sprintf("%d", githubUser.ID), email, githubUser.Name, githubUser.AvatarURL) +} + +func handleSocialLogin(c *gin.Context, provider, providerID, email, name, avatarURL string) { + var user models.User + var socialAccount models.SocialAccount + + // Check if social account exists + err := database.DB.Where("provider = ? AND provider_id = ?", provider, providerID).First(&socialAccount).Error + + if err == nil { + // Found social account, find user + if err := database.DB.First(&user, socialAccount.UserID).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "User record missing for social account"}) + return + } + } else { + // Social account not found. Check if email exists + if err := database.DB.Where("email = ?", email).First(&user).Error; err == nil { + // User exists, add social account + newSocial := models.SocialAccount{ + UserID: uint64(user.ID), + Provider: provider, + ProviderID: providerID, + Email: email, + Name: name, + AvatarURL: avatarURL, + } + database.DB.Create(&newSocial) + } else { + // Create new user + verified := true + now := time.Now() + // Generate random password + randomPass := generateSecureToken() + hashedPwd, _ := utils.HashPassword(randomPass) + + user = models.User{ + UserName: name, // Handle duplicate usernames? + Email: email, + Password: hashedPwd, + EmailVerified: &verified, + EmailVerifiedAt: &now, + } + // Fallback username if empty + if user.UserName == "" { + user.UserName = strings.Split(email, "@")[0] + } + + if err := database.DB.Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create user: " + err.Error()}) + return + } + + newSocial := models.SocialAccount{ + UserID: uint64(user.ID), + Provider: provider, + ProviderID: providerID, + Email: email, + Name: name, + AvatarURL: avatarURL, + } + database.DB.Create(&newSocial) + } + } + + // Login logic + isAdmin := false + if user.IsAdmin != nil && *user.IsAdmin { + isAdmin = true + } + + jwtService := services.NewJWTService() + accessToken, err := jwtService.GenerateToken(user.ID, isAdmin) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"}) + return + } + refreshToken, err := jwtService.GenerateRefreshToken(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate refresh token"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "user": toUserResponse(user), + "access_token": accessToken, + "refresh_token": refreshToken, + }) +} diff --git a/app/controllers/BlogContraller.go b/app/controllers/BlogContraller.go new file mode 100644 index 0000000..2a319f6 --- /dev/null +++ b/app/controllers/BlogContraller.go @@ -0,0 +1,2289 @@ +package controllers + +import ( + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + database "goGin/app/database/config" + "goGin/app/database/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// Post payloads +type PostPayload struct { + Title string `json:"title" binding:"required" form:"title"` + Slug string `json:"slug" form:"slug"` + Images string `json:"images" form:"images"` + Content string `json:"content" form:"content"` + CategoryIDs []uint `json:"category_ids" form:"category_ids"` + TagNames []string `json:"tag_names" form:"tag_names"` +} + +// Post CRUD +// CreatePost godoc +// @Summary Create a post +// @Description Create a new blog post (supports multipart/form-data with image upload) +// @Tags posts +// @Security BearerAuth +// @Accept multipart/form-data +// @Produce json +// @Param title formData string true "Title" +// @Param slug formData string false "Slug" +// @Param content formData string false "Content" +// @Param category_ids formData []int false "Category IDs (repeatable)" +// @Param tag_names formData []string false "Tag names (repeatable)" +// @Param images formData file false "Image files (use 'images' or 'image' fields)" +// @Param width formData int false "Image width" +// @Param height formData int false "Image height" +// @Param quality formData int false "Image quality" +// @Param format formData string false "Image format" +// @Success 201 {object} controllers.PostResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/posts [post] +func CreatePost(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + + // Determine whether request is multipart/form-data reliably. + contentType := c.GetHeader("Content-Type") + isMultipart := false + if contentType != "" { + if strings.Contains(contentType, "multipart/form-data") { + isMultipart = true + } + } + // Also try parsing MultipartForm to be robust if header is missing/varies + if !isMultipart { + if _, err := c.MultipartForm(); err == nil { + isMultipart = true + } + } + + var payload PostPayload + var width, height, quality int + var format string + var imagePaths []string + + if isMultipart { + // parse basic fields from form + payload.Title = c.PostForm("title") + if payload.Title == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) + return + } + payload.Slug = c.PostForm("slug") + payload.Content = c.PostForm("content") + // parse repeated form fields + catStrs := c.PostFormArray("category_ids") + for _, s := range catStrs { + if s == "" { + continue + } + if id, err := strconv.Atoi(s); err == nil && id > 0 { + payload.CategoryIDs = append(payload.CategoryIDs, uint(id)) + } + } + payload.TagNames = c.PostFormArray("tag_names") + // image metadata + if v := c.PostForm("width"); v != "" { + if wi, err := strconv.Atoi(v); err == nil { + width = wi + } + } + if v := c.PostForm("height"); v != "" { + if hi, err := strconv.Atoi(v); err == nil { + height = hi + } + } + if v := c.PostForm("quality"); v != "" { + if qi, err := strconv.Atoi(v); err == nil { + quality = qi + } + } + format = c.PostForm("format") + + // handle file uploads (support multiple files under 'images' or single 'image') + if form, err := c.MultipartForm(); err == nil && form != nil { + files := form.File["images"] + if len(files) == 0 { + files = form.File["image"] + } + if len(files) > 0 { + uploadDir := filepath.Join("uploads", "posts") + if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"}) + return + } + for i, file := range files { + ext := filepath.Ext(file.Filename) + newName := fmt.Sprintf("post-%d-%d%s", time.Now().UnixNano(), i, ext) + destination := filepath.Join(uploadDir, newName) + if err := c.SaveUploadedFile(file, destination); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + imagePaths = append(imagePaths, "/uploads/posts/"+newName) + } + } + } + // if client provided image paths as form string + if imgStr := c.PostForm("images"); imgStr != "" && len(imagePaths) == 0 { + // accept comma separated paths + for _, p := range strings.Split(imgStr, ",") { + p = strings.TrimSpace(p) + if p != "" { + imagePaths = append(imagePaths, p) + } + } + } + } else { + // JSON path + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // If JSON includes width/height/quality/format fields, map them + // Try reading them from payload.Images if encoded or rely on frontend sending them in payload (not implemented here) + } + + post := models.Post{ + Title: payload.Title, + Slug: payload.Slug, + Images: strings.Join(imagePaths, ","), + Content: payload.Content, + Width: width, + Height: height, + Quality: quality, + Format: format, + } + + // Transaction and associations same as before + tx := database.DB.Begin() + if tx.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) + return + } + + if err := tx.Create(&post).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Attach categories if provided + if len(payload.CategoryIDs) > 0 { + var cats []models.Category + if err := tx.Where("id IN ?", payload.CategoryIDs).Find(&cats).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := tx.Model(&post).Association("Categories").Replace(&cats); err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + // Attach tags (create if not exists) + if len(payload.TagNames) > 0 { + var tags []models.Tag + for _, name := range payload.TagNames { + if name == "" { + continue + } + var tag models.Tag + if err := tx.Where("name = ?", name).First(&tag).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + tag = models.Tag{Name: name} + if err := tx.Create(&tag).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } else { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + tags = append(tags, tag) + } + if err := tx.Model(&post).Association("Tags").Replace(&tags); err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) + return + } + + // reload with associations + if err := database.DB.Preload("Categories").Preload("Tags").First(&post, post.ID).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{"data": post}) +} + +// GetPost godoc +// @Summary Get a post by slug +// @Description Return a single post found by slug +// @Tags posts +// @Produce json +// @Param slug path string true "Post slug" +// @Success 200 {object} controllers.PostResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/posts/{slug} [get] +func GetPost(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + // slug param (router uses :slug) + slug := c.Param("slug") + if slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slug"}) + return + } + var post models.Post + if err := database.DB.Preload("Categories").Preload("Tags").Where("slug = ?", slug).First(&post).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": post}) +} + +// ListPosts godoc +// @Summary List posts +// @Description List posts with pagination and optional filters +// @Tags posts +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param category_id query int false "Filter by category id" +// @Param tag_id query int false "Filter by tag id" +// @Param q query string false "Search query" +// @Success 200 {object} controllers.PostListResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/posts [get] +func ListPosts(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 100 { + perPage = 100 + } + offset := (page - 1) * perPage + + query := database.DB.Model(&models.Post{}).Preload("Categories").Preload("Tags") + + // filters + if catStr := c.Query("category_id"); catStr != "" { + if catID, err := strconv.Atoi(catStr); err == nil && catID > 0 { + query = query.Joins("JOIN post_categories pc ON pc.post_id = posts.id").Where("pc.category_id = ?", catID) + } + } + if tagStr := c.Query("tag_id"); tagStr != "" { + if tagID, err := strconv.Atoi(tagStr); err == nil && tagID > 0 { + query = query.Joins("JOIN post_tags pt ON pt.post_id = posts.id").Where("pt.tag_id = ?", tagID) + } + } + if q := c.Query("q"); q != "" { + like := "%" + q + "%" + query = query.Where("title LIKE ? OR content LIKE ?", like, like) + } + + var total int64 + if err := query.Distinct("posts.id").Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var posts []models.Post + if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&posts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"items": posts, "total": total, "page": page, "per_page": perPage}) +} + +// Admin: List posts (supports soft-delete filter) +// AdminListPosts godoc +// @Summary Admin: List posts (supports soft-delete filter) +// @Description Admin listing of posts. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. +// @Tags posts +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param category_id query int false "Filter by category id" +// @Param tag_id query int false "Filter by tag id" +// @Param q query string false "Search query" +// @Param soft query string false "Soft delete filter: only|with" +// @Success 200 {object} controllers.PostListResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/posts [get] +func AdminListPosts(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + soft := c.Query("soft") + var query *gorm.DB + if soft == "only" { + query = database.DB.Unscoped().Model(&models.Post{}).Where("deleted_at IS NOT NULL") + } else if soft == "with" { + query = database.DB.Unscoped().Model(&models.Post{}) + } else { + query = database.DB.Model(&models.Post{}) + } + query = query.Preload("Categories").Preload("Tags") + + if catStr := c.Query("category_id"); catStr != "" { + if catID, err := strconv.Atoi(catStr); err == nil && catID > 0 { + query = query.Joins("JOIN post_categories pc ON pc.post_id = posts.id").Where("pc.category_id = ?", catID) + } + } + if tagStr := c.Query("tag_id"); tagStr != "" { + if tagID, err := strconv.Atoi(tagStr); err == nil && tagID > 0 { + query = query.Joins("JOIN post_tags pt ON pt.post_id = posts.id").Where("pt.tag_id = ?", tagID) + } + } + if q := c.Query("q"); q != "" { + like := "%" + q + "%" + query = query.Where("title LIKE ? OR content LIKE ?", like, like) + } + + var total int64 + if err := query.Distinct("posts.id").Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var posts []models.Post + if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&posts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"items": posts, "total": total, "page": page, "per_page": perPage}) +} + +// AdminGetPost godoc +// @Summary Admin: Get a post by id +// @Description Return a single post by id with categories and tags +// @Tags posts +// @Security BearerAuth +// @Produce json +// @Param id path int true "Post ID" +// @Success 200 {object} controllers.PostResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/posts/{id} [get] +func AdminGetPost(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var post models.Post + if err := database.DB.Unscoped().Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": post}) +} + +// UpdatePost godoc +// @Summary Update a post +// @Description Update an existing blog post (supports multipart/form-data with image upload) +// @Tags posts +// @Security BearerAuth +// @Accept multipart/form-data +// @Produce json +// @Param id path int true "Post ID" +// @Param title formData string false "Title" +// @Param slug formData string false "Slug" +// @Param content formData string false "Content" +// @Param category_ids formData []int false "Category IDs (repeatable)" +// @Param tag_names formData []string false "Tag names (repeatable)" +// @Param images formData file false "Image files (use 'images' or 'image' fields)" +// @Param width formData int false "Image width" +// @Param height formData int false "Image height" +// @Param quality formData int false "Image quality" +// @Param format formData string false "Image format" +// @Success 200 {object} controllers.PostResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/posts/{id} [put] +func UpdatePost(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + + // id param (router uses :id) + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var post models.Post + if err := database.DB.Where("id = ?", id).First(&post).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Determine multipart + contentType := c.GetHeader("Content-Type") + isMultipart := false + if contentType != "" { + if strings.Contains(contentType, "multipart/form-data") { + isMultipart = true + } + } + if !isMultipart { + if _, err := c.MultipartForm(); err == nil { + isMultipart = true + } + } + + var payload PostPayload + var width, height, quality int + var format string + var imagePaths []string + + if isMultipart { + // read optional fields and update only when provided + if v := c.PostForm("title"); v != "" { + post.Title = v + } + if v := c.PostForm("slug"); v != "" { + post.Slug = v + } + if v := c.PostForm("content"); v != "" { + post.Content = v + } + // categories + catStrs := c.PostFormArray("category_ids") + if len(catStrs) > 0 { + var catIDs []uint + for _, s := range catStrs { + if s == "" { + continue + } + if idn, err := strconv.Atoi(s); err == nil && idn > 0 { + catIDs = append(catIDs, uint(idn)) + } + } + payload.CategoryIDs = catIDs + } + // tags + tagArr := c.PostFormArray("tag_names") + if len(tagArr) > 0 { + payload.TagNames = tagArr + } + // image metadata + if v := c.PostForm("width"); v != "" { + if wi, err := strconv.Atoi(v); err == nil { + width = wi + } + } + if v := c.PostForm("height"); v != "" { + if hi, err := strconv.Atoi(v); err == nil { + height = hi + } + } + if v := c.PostForm("quality"); v != "" { + if qi, err := strconv.Atoi(v); err == nil { + quality = qi + } + } + if v := c.PostForm("format"); v != "" { + format = v + } + + // handle file uploads + if form, err := c.MultipartForm(); err == nil && form != nil { + files := form.File["images"] + if len(files) == 0 { + files = form.File["image"] + } + if len(files) > 0 { + uploadDir := filepath.Join("uploads", "posts") + if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"}) + return + } + for i, file := range files { + ext := filepath.Ext(file.Filename) + newName := fmt.Sprintf("post-%d-%d%s", time.Now().UnixNano(), i, ext) + destination := filepath.Join(uploadDir, newName) + if err := c.SaveUploadedFile(file, destination); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + imagePaths = append(imagePaths, "/uploads/posts/"+newName) + } + } + } + // if form provided images as string + if imgStr := c.PostForm("images"); imgStr != "" && len(imagePaths) == 0 { + for _, p := range strings.Split(imgStr, ",") { + p = strings.TrimSpace(p) + if p != "" { + imagePaths = append(imagePaths, p) + } + } + } + + // apply imagePaths if any + if len(imagePaths) > 0 { + post.Images = strings.Join(imagePaths, ",") + } + // apply image metadata if provided + if width != 0 { + post.Width = width + } + if height != 0 { + post.Height = height + } + if quality != 0 { + post.Quality = quality + } + if format != "" { + post.Format = format + } + + } else { + // JSON path - keep previous behavior but allow partial updates + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if payload.Title != "" { + post.Title = payload.Title + } + if payload.Slug != "" { + post.Slug = payload.Slug + } + if payload.Content != "" { + post.Content = payload.Content + } + if payload.Images != "" { + post.Images = payload.Images + } + if len(payload.CategoryIDs) > 0 { + /* handled below via payload */ + } + if len(payload.TagNames) > 0 { + /* handled below via payload */ + } + } + + // Transaction + tx := database.DB.Begin() + if tx.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) + return + } + + if err := tx.Save(&post).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Update categories + if len(payload.CategoryIDs) > 0 { + // filter out invalid/zero ids + var filtered []uint + for _, cid := range payload.CategoryIDs { + if cid > 0 { + filtered = append(filtered, cid) + } + } + if len(filtered) > 0 { + var cats []models.Category + if err := tx.Where("id IN ?", filtered).Find(&cats).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := tx.Model(&post).Association("Categories").Replace(&cats); err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } else { + // filtered list empty -> do nothing (avoid clearing categories when client sends [0]) + } + } else { + // If no categories provided in payload and request was multipart, do nothing (keep existing) + } + + // Update tags + if len(payload.TagNames) > 0 { + var tags []models.Tag + for _, name := range payload.TagNames { + if name == "" { + continue + } + var tag models.Tag + if err := tx.Where("name = ?", name).First(&tag).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + tag = models.Tag{Name: name} + if err := tx.Create(&tag).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } else { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + tags = append(tags, tag) + } + if err := tx.Model(&post).Association("Tags").Replace(&tags); err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) + return + } + + // reload with associations + if err := database.DB.Preload("Categories").Preload("Tags").First(&post, post.ID).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": post}) +} + +// DeletePost godoc +// @Summary Delete a post +// @Description Delete a blog post by ID +// @Tags posts +// @Security BearerAuth +// @Produce json +// @Param id path int true "Post ID" +// @Success 204 {object} nil +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/posts/{id} [delete] +func DeletePost(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + + // id param (router uses :id) + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var post models.Post + if err := database.DB.Where("id = ?", id).First(&post).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Delete the post (soft delete if model has DeletedAt) + if err := database.DB.Delete(&post).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Return a helpful JSON response instead of empty body + c.JSON(http.StatusOK, gin.H{ + "message": "post deleted successfully", + "id": post.ID, + }) +} + +// Category CRUD +func GetCategory(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + // slug param + slug := c.Param("slug") + if slug == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slug"}) + return + } + var cat models.Category + if err := database.DB.Preload("Posts").Where("slug = ?", slug).First(&cat).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": cat}) +} + +// Payloads for other models +type CategoryPayload struct { + Title string `json:"title" binding:"required"` + Slug string `json:"slug"` + Description string `json:"description"` + ParentID *uint `json:"parent_id"` +} + +type TagPayload struct { + Name string `json:"name" binding:"required"` +} + +type CommentPayload struct { + UserID uint `json:"user_id" binding:"required"` + PostID uint `json:"post_id" binding:"required"` + Body string `json:"body" binding:"required"` +} + +type CategoryViewPayload struct { + CategoryID uint `json:"category_id" binding:"required"` + IPAddress string `json:"ip_address"` +} + +// Admin: CreateCategory +// CreateCategory godoc +// @Summary Create a category +// @Description Create a new category (admin) +// @Tags categories +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param category body CategoryPayload true "Category payload" +// @Success 201 {object} controllers.CategorySimple +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categories [post] +func CreateCategory(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + var payload CategoryPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cat := models.Category{Title: payload.Title, Slug: payload.Slug, Description: payload.Description, ParentID: payload.ParentID} + if err := database.DB.Create(&cat).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + resp := CategorySimple{ID: cat.ID, Title: cat.Title, Slug: cat.Slug, ParentID: cat.ParentID} + c.JSON(http.StatusCreated, gin.H{"data": resp}) +} + +// Admin: UpdateCategory +// UpdateCategory godoc +// @Summary Update a category +// @Description Update an existing category (admin) +// @Tags categories +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "Category ID" +// @Param category body CategoryPayload true "Category payload" +// @Success 200 {object} controllers.CategorySimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categories/{id} [put] +func UpdateCategory(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var payload CategoryPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var cat models.Category + if err := database.DB.First(&cat, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + cat.Title = payload.Title + cat.Slug = payload.Slug + cat.Description = payload.Description + cat.ParentID = payload.ParentID + if err := database.DB.Save(&cat).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + resp := CategorySimple{ID: cat.ID, Title: cat.Title, Slug: cat.Slug, ParentID: cat.ParentID} + c.JSON(http.StatusOK, gin.H{"data": resp}) +} + +// Admin: DeleteCategory +// DeleteCategory godoc +// @Summary Delete a category +// @Description Soft-delete a category (admin) +// @Tags categories +// @Security BearerAuth +// @Produce json +// @Param id path int true "Category ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categories/{id} [delete] +func DeleteCategory(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var cat models.Category + if err := database.DB.First(&cat, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := database.DB.Delete(&cat).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": "category deleted successfully", + "id": cat.ID, + }) +} + +// Public: GetTag +// GetTag godoc +// @Summary Get a tag by id +// @Description Return a single tag by id +// @Tags tags +// @Produce json +// @Param id path int true "Tag ID" +// @Success 200 {object} controllers.TagSimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/tags/{id} [get] +func GetTag(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var t models.Tag + if err := database.DB.First(&t, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": TagSimple{ID: t.ID, Name: t.Name}}) +} + +// Admin: CreateTag +// CreateTag godoc +// @Summary Create a tag +// @Description Create a new tag (admin) +// @Tags tags +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param tag body TagPayload true "Tag payload" +// @Success 201 {object} controllers.TagSimple +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/tags [post] +func CreateTag(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + var payload TagPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + tag := models.Tag{Name: payload.Name} + if err := database.DB.Create(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": TagSimple{ID: tag.ID, Name: tag.Name}}) +} + +// Admin: UpdateTag +// UpdateTag godoc +// @Summary Update a tag +// @Description Update an existing tag (admin) +// @Tags tags +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "Tag ID" +// @Param tag body TagPayload true "Tag payload" +// @Success 200 {object} controllers.TagSimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/tags/{id} [put] +func UpdateTag(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var payload TagPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var tag models.Tag + if err := database.DB.First(&tag, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + tag.Name = payload.Name + if err := database.DB.Save(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": TagSimple{ID: tag.ID, Name: tag.Name}}) +} + +// Admin: DeleteTag +// DeleteTag godoc +// @Summary Delete a tag +// @Description Soft-delete a tag (admin) +// @Tags tags +// @Security BearerAuth +// @Produce json +// @Param id path int true "Tag ID" +// @Success 204 {object} nil +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/tags/{id} [delete] +func DeleteTag(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var tag models.Tag + if err := database.DB.First(&tag, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := database.DB.Delete(&tag).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +// RestoreTag godoc +// @Summary Restore a soft-deleted tag +// @Description Restore a tag that has been soft-deleted (admin) +// @Tags tags +// @Security BearerAuth +// @Produce json +// @Param id path int true "Tag ID" +// @Success 200 {object} controllers.TagSimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/tags/{id}/restore [post] +func RestoreTag(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var tag models.Tag + if err := database.DB.Unscoped().First(&tag, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "tag not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if tag.DeletedAt.Valid { + if err := database.DB.Unscoped().Model(&tag).Update("deleted_at", nil).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"data": TagSimple{ID: tag.ID, Name: tag.Name}}) +} + +// Public: GetComment +// GetComment godoc +// @Summary Get a comment by id +// @Description Return a single comment by id +// @Tags comments +// @Produce json +// @Param id path int true "Comment ID" +// @Success 200 {object} controllers.CommentSimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/comments/{id} [get] +func GetComment(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var cm models.Comment + if err := database.DB.First(&cm, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": CommentSimple{ID: cm.ID, UserID: cm.UserID, PostID: cm.PostID, Body: cm.Body, Created: cm.CreatedAt}}) +} + +// Admin: CreateComment +// CreateComment godoc +// @Summary Create a comment (admin) +// @Description Create a comment as admin +// @Tags comments +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param comment body CommentPayload true "Comment payload" +// @Success 201 {object} controllers.CommentSimple +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/comments [post] +func CreateComment(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + var payload CommentPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cm := models.Comment{UserID: payload.UserID, PostID: payload.PostID, Body: payload.Body} + if err := database.DB.Create(&cm).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": CommentSimple{ID: cm.ID, UserID: cm.UserID, PostID: cm.PostID, Body: cm.Body, Created: cm.CreatedAt}}) +} + +// Admin: UpdateComment +// UpdateComment godoc +// @Summary Update a comment (admin) +// @Description Update a comment as admin +// @Tags comments +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "Comment ID" +// @Param comment body CommentPayload true "Comment payload" +// @Success 200 {object} controllers.CommentSimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/comments/{id} [put] +func UpdateComment(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var payload CommentPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var cm models.Comment + if err := database.DB.First(&cm, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + cm.Body = payload.Body + if err := database.DB.Save(&cm).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": CommentSimple{ID: cm.ID, UserID: cm.UserID, PostID: cm.PostID, Body: cm.Body, Created: cm.CreatedAt}}) +} + +// Admin: DeleteComment +// DeleteComment godoc +// @Summary Delete a comment (admin) +// @Description Soft-delete a comment as admin +// @Tags comments +// @Security BearerAuth +// @Produce json +// @Param id path int true "Comment ID" +// @Success 204 {object} nil +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/comments/{id} [delete] +func DeleteComment(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var cm models.Comment + if err := database.DB.First(&cm, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "comment not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := database.DB.Delete(&cm).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.Status(http.StatusNoContent) +} + +// Public: GetCategoryView +// GetCategoryView godoc +// @Summary Get a category view by id +// @Description Return a single category view by id +// @Tags categoryviews +// @Produce json +// @Param id path int true "CategoryView ID" +// @Success 200 {object} controllers.CategoryViewSimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/categoryviews/{id} [get] +func GetCategoryView(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var cv models.CategoryView + if err := database.DB.First(&cv, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category view not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": CategoryViewSimple{ID: cv.ID, CategoryID: cv.CategoryID, IPAddress: cv.IPAddress, Created: cv.CreatedAt}}) +} + +// Admin: CreateCategoryView +// CreateCategoryView godoc +// @Summary Create a category view (admin) +// @Description Create a category view as admin +// @Tags categoryviews +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param view body CategoryViewPayload true "CategoryView payload" +// @Success 201 {object} controllers.CategoryViewSimple +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categoryviews [post] +func CreateCategoryView(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + var payload CategoryViewPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + cv := models.CategoryView{CategoryID: payload.CategoryID, IPAddress: payload.IPAddress} + if err := database.DB.Create(&cv).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": CategoryViewSimple{ID: cv.ID, CategoryID: cv.CategoryID, IPAddress: cv.IPAddress, Created: cv.CreatedAt}}) +} + +// İlişkili işlemler +func FilterPostsByTag(c *gin.Context) { _ = c } + +// ListDeletedPosts godoc +// @Summary List soft-deleted posts +// @Description List posts that have been soft-deleted with pagination +// @Tags posts +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} controllers.PostListResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/posts/deleted [get] +func ListDeletedPosts(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + query := database.DB.Unscoped().Model(&models.Post{}).Where("deleted_at IS NOT NULL").Preload("Categories").Preload("Tags") + + var total int64 + if err := query.Distinct("posts.id").Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var posts []models.Post + if err := query.Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&posts).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var respItems []PostResponse + for _, p := range posts { + var cats []CategorySimple + for _, cc := range p.Categories { + cats = append(cats, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) + } + var tags []TagSimple + for _, t := range p.Tags { + tags = append(tags, TagSimple{ID: t.ID, Name: t.Name}) + } + respItems = append(respItems, PostResponse{ + ID: p.ID, + Title: p.Title, + Slug: p.Slug, + Images: p.Images, + Content: p.Content, + Categories: cats, + Tags: tags, + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"items": respItems, "total": total, "page": page, "per_page": perPage}) +} + +// RestorePost godoc +// @Summary Restore a soft-deleted post +// @Description Restore a post and its related comments (if soft-deleted) +// @Tags posts +// @Security BearerAuth +// @Produce json +// @Param id path int true "Post ID" +// @Success 200 {object} controllers.PostResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/posts/{id}/restore [post] +func RestorePost(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + tx := database.DB.Begin() + if tx.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) + return + } + + var post models.Post + if err := tx.Unscoped().Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if post.DeletedAt.Time.IsZero() { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{"error": "post is not deleted"}) + return + } + + // restore post + if err := tx.Unscoped().Model(&post).Update("deleted_at", nil).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // restore related comments (if any) + if err := tx.Unscoped().Model(&models.Comment{}).Where("post_id = ? AND deleted_at IS NOT NULL", post.ID).Update("deleted_at", nil).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) + return + } + + if err := database.DB.Preload("Categories").Preload("Tags").First(&post, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var cats []CategorySimple + for _, cc := range post.Categories { + cats = append(cats, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) + } + var tags []TagSimple + for _, t := range post.Tags { + tags = append(tags, TagSimple{ID: t.ID, Name: t.Name}) + } + resp := PostResponse{ID: post.ID, Title: post.Title, Slug: post.Slug, Images: post.Images, Content: post.Content, Categories: cats, Tags: tags, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt} + c.JSON(http.StatusOK, gin.H{"data": resp}) +} + +// ListDeletedCategories godoc +// @Summary List soft-deleted categories +// @Description List categories that have been soft-deleted with pagination +// @Tags categories +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categories/deleted [get] +func ListDeletedCategories(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + query := database.DB.Unscoped().Model(&models.Category{}).Where("deleted_at IS NOT NULL") + + var total int64 + if err := query.Distinct("categories.id").Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var cats []models.Category + if err := query.Order("deleted_at desc").Limit(perPage).Offset(offset).Find(&cats).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var resp []CategorySimple + for _, cc := range cats { + resp = append(resp, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) + } + + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// RestoreCategory godoc +// @Summary Restore a soft-deleted category +// @Description Restore a category and related posts/comments if soft-deleted +// @Tags categories +// @Security BearerAuth +// @Produce json +// @Param id path int true "Category ID" +// @Success 200 {object} controllers.CategorySimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categories/{id}/restore [post] +func RestoreCategory(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + tx := database.DB.Begin() + if tx.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) + return + } + + var cat models.Category + if err := tx.Unscoped().First(&cat, id).Error; err != nil { + tx.Rollback() + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if cat.DeletedAt.Time.IsZero() { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{"error": "category is not deleted"}) + return + } + + // restore category + if err := tx.Unscoped().Model(&cat).Update("deleted_at", nil).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // find post ids related to this category that are soft-deleted + var postIDs []uint + if err := tx.Table("posts").Select("posts.id").Joins("JOIN post_categories pc ON pc.post_id = posts.id").Where("pc.category_id = ? AND posts.deleted_at IS NOT NULL", cat.ID).Scan(&postIDs).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if len(postIDs) > 0 { + // restore posts + if err := tx.Unscoped().Model(&models.Post{}).Where("id IN ?", postIDs).Update("deleted_at", nil).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // restore comments for those posts + if err := tx.Unscoped().Model(&models.Comment{}).Where("post_id IN ? AND deleted_at IS NOT NULL", postIDs).Update("deleted_at", nil).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) + return + } + + resp := CategorySimple{ID: cat.ID, Title: cat.Title, Slug: cat.Slug, ParentID: cat.ParentID} + c.JSON(http.StatusOK, gin.H{"data": resp}) +} + +// ListCategories godoc +// @Summary List categories +// @Description List categories with pagination +// @Tags categories +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/categories [get] +func ListCategories(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + var total int64 + if err := database.DB.Model(&models.Category{}).Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var cats []models.Category + if err := database.DB.Order("created_at desc").Limit(perPage).Offset(offset).Find(&cats).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var resp []CategorySimple + for _, cc := range cats { + resp = append(resp, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) + } + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// AdminListCategories godoc +// @Summary Admin: List categories (supports soft-delete filter) +// @Description Admin listing of categories. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. +// @Tags categories +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param soft query string false "Soft delete filter: only|with" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categories [get] +func AdminListCategories(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + soft := c.Query("soft") + var query *gorm.DB + if soft == "only" { + query = database.DB.Unscoped().Model(&models.Category{}).Where("deleted_at IS NOT NULL") + } else if soft == "with" { + query = database.DB.Unscoped().Model(&models.Category{}) + } else { + query = database.DB.Model(&models.Category{}) + } + query = query.Table("categories") + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []struct { + ID uint `gorm:"column:id"` + Title string `gorm:"column:title"` + Slug string `gorm:"column:slug"` + ParentID *uint `gorm:"column:parent_id"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` + } + if err := query.Select("id", "title", "slug", "parent_id", "deleted_at").Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var resp []AdminCategoryListItem + for _, v := range items { + item := AdminCategoryListItem{ID: v.ID, Title: v.Title, Slug: v.Slug, ParentID: v.ParentID} + if v.DeletedAt.Valid { + item.DeletedAt = &v.DeletedAt.Time + } + resp = append(resp, item) + } + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// ListTags godoc +// @Summary List tags +// @Description List tags with pagination +// @Tags tags +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/tags [get] +func ListTags(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + var total int64 + if err := database.DB.Model(&models.Tag{}).Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []models.Tag + if err := database.DB.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var resp []TagSimple + for _, v := range items { + resp = append(resp, TagSimple{ID: v.ID, Name: v.Name}) + } + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// AdminListTags godoc +// @Summary Admin: List tags (supports soft-delete filter) +// @Description Admin listing of tags. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. +// @Tags tags +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param soft query string false "Soft delete filter: only|with" +// @Success 200 {object} map[string]interface{} "items: []AdminTagListItem (includes deleted_at when soft=only or soft=with)" +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/tags [get] +func AdminListTags(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + soft := c.Query("soft") + var query *gorm.DB + if soft == "only" { + query = database.DB.Unscoped().Model(&models.Tag{}).Where("deleted_at IS NOT NULL") + } else if soft == "with" { + query = database.DB.Unscoped().Model(&models.Tag{}) + } else { + query = database.DB.Model(&models.Tag{}) + } + query = query.Table("tags") + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []struct { + ID uint `gorm:"column:id"` + Name string `gorm:"column:name"` + DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"` + } + if err := query.Select("id", "name", "deleted_at").Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var resp []AdminTagListItem + for _, v := range items { + item := AdminTagListItem{ID: v.ID, Name: v.Name} + if v.DeletedAt.Valid { + item.DeletedAt = &v.DeletedAt.Time + } + resp = append(resp, item) + } + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// ListComments godoc +// @Summary List comments +// @Description List comments with pagination +// @Tags comments +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/comments [get] +func ListComments(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + var total int64 + if err := database.DB.Model(&models.Comment{}).Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []models.Comment + if err := database.DB.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var resp []CommentSimple + for _, v := range items { + resp = append(resp, CommentSimple{ID: v.ID, UserID: v.UserID, PostID: v.PostID, Body: v.Body, Created: v.CreatedAt}) + } + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// AdminListComments godoc +// @Summary Admin: List comments (supports soft-delete filter) +// @Description Admin listing of comments. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. +// @Tags comments +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param soft query string false "Soft delete filter: only|with" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/comments [get] +func AdminListComments(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + soft := c.Query("soft") + var query *gorm.DB + if soft == "only" { + query = database.DB.Unscoped().Model(&models.Comment{}).Where("deleted_at IS NOT NULL") + } else if soft == "with" { + query = database.DB.Unscoped().Model(&models.Comment{}) + } else { + query = database.DB.Model(&models.Comment{}) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []models.Comment + if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var resp []CommentSimple + for _, v := range items { + resp = append(resp, CommentSimple{ID: v.ID, UserID: v.UserID, PostID: v.PostID, Body: v.Body, Created: v.CreatedAt}) + } + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// ListCategoryViews godoc +// @Summary List category views +// @Description List category views with pagination +// @Tags categoryviews +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/categoryviews [get] +func ListCategoryViews(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + var total int64 + if err := database.DB.Model(&models.CategoryView{}).Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []models.CategoryView + if err := database.DB.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var resp []CategoryViewSimple + for _, v := range items { + resp = append(resp, CategoryViewSimple{ID: v.ID, CategoryID: v.CategoryID, IPAddress: v.IPAddress, Created: v.CreatedAt}) + } + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// AdminListCategoryViews godoc +// @Summary Admin: List category views (supports soft-delete filter) +// @Description Admin listing of category views. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted. +// @Tags categoryviews +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param soft query string false "Soft delete filter: only|with" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categoryviews [get] +func AdminListCategoryViews(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + soft := c.Query("soft") + var query *gorm.DB + if soft == "only" { + query = database.DB.Unscoped().Model(&models.CategoryView{}).Where("deleted_at IS NOT NULL") + } else if soft == "with" { + query = database.DB.Unscoped().Model(&models.CategoryView{}) + } else { + query = database.DB.Model(&models.CategoryView{}) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []models.CategoryView + if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var resp []CategoryViewSimple + for _, v := range items { + resp = append(resp, CategoryViewSimple{ID: v.ID, CategoryID: v.CategoryID, IPAddress: v.IPAddress, Created: v.CreatedAt}) + } + c.JSON(http.StatusOK, gin.H{"items": resp, "total": total, "page": page, "per_page": perPage}) +} + +// Admin: AddCommentToPost +// AddCommentToPost godoc +// @Summary Add a comment to a post (admin) +// @Description Add a comment to a specific post as admin +// @Tags posts +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "Post ID" +// @Param comment body CommentPayload true "Comment payload" +// @Success 201 {object} controllers.CommentSimple +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/posts/{id}/comments [post] +func AddCommentToPost(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid post id"}) + return + } + var payload CommentPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var post models.Post + if err := database.DB.First(&post, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "post not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + cm := models.Comment{UserID: payload.UserID, PostID: uint(id), Body: payload.Body} + if err := database.DB.Create(&cm).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": CommentSimple{ID: cm.ID, UserID: cm.UserID, PostID: cm.PostID, Body: cm.Body, Created: cm.CreatedAt}}) +} + +// Admin: AddPostToCategory +// AddPostToCategory godoc +// @Summary Add a post to a category (admin) +// @Description Create a post and attach it to the given category +// @Tags categories +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "Category ID" +// @Param post body PostPayload true "Post payload" +// @Success 201 {object} controllers.PostResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/categories/{id}/posts [post] +func AddPostToCategory(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + catIDStr := c.Param("id") + catID, err := strconv.Atoi(catIDStr) + if err != nil || catID < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid category id"}) + return + } + var payload PostPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + var cat models.Category + if err := database.DB.First(&cat, catID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "category not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + post := models.Post{Title: payload.Title, Slug: payload.Slug, Images: payload.Images, Content: payload.Content} + tx := database.DB.Begin() + if tx.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start transaction"}) + return + } + if err := tx.Create(&post).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := tx.Model(&post).Association("Categories").Append(&cat); err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if len(payload.CategoryIDs) > 0 { + var cats []models.Category + if err := tx.Where("id IN ?", payload.CategoryIDs).Find(&cats).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := tx.Model(&post).Association("Categories").Replace(&cats); err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + if len(payload.TagNames) > 0 { + var tags []models.Tag + for _, name := range payload.TagNames { + if name == "" { + continue + } + var tag models.Tag + if err := tx.Where("name = ?", name).First(&tag).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + tag = models.Tag{Name: name} + if err := tx.Create(&tag).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } else { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + tags = append(tags, tag) + } + if err := tx.Model(&post).Association("Tags").Replace(&tags); err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"}) + return + } + if err := database.DB.Preload("Categories").Preload("Tags").First(&post, post.ID).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + var catsResp []CategorySimple + for _, cc := range post.Categories { + catsResp = append(catsResp, CategorySimple{ID: cc.ID, Title: cc.Title, Slug: cc.Slug, ParentID: cc.ParentID}) + } + var tagsResp []TagSimple + for _, t := range post.Tags { + tagsResp = append(tagsResp, TagSimple{ID: t.ID, Name: t.Name}) + } + resp := PostResponse{ID: post.ID, Title: post.Title, Slug: post.Slug, Images: post.Images, Content: post.Content, Categories: catsResp, Tags: tagsResp, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt} + c.JSON(http.StatusCreated, gin.H{"data": resp}) +} diff --git a/app/controllers/HeroController.go b/app/controllers/HeroController.go new file mode 100644 index 0000000..1be8487 --- /dev/null +++ b/app/controllers/HeroController.go @@ -0,0 +1,538 @@ +package controllers + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + database "goGin/app/database/config" + "goGin/app/database/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// Hero payload +type HeroPayload struct { + Color string `json:"color" binding:"required"` + Title string `json:"title"` + Text1 string `json:"text1"` + Text2 string `json:"text2"` + Text4 string `json:"text4"` + Text5 string `json:"text5"` + Image string `json:"image"` + IsActive *bool `json:"is_active"` +} + +// AdminListHeroes godoc +// @Summary Admin: List heroes +// @Description Admin listing of heroes. Use ?soft=only to list deleted, ?soft=with to include deleted. +// @Tags heroes +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param soft query string false "Soft delete filter: only|with" +// @Success 200 {object} controllers.HeroListResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/heroes [get] +func AdminListHeroes(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + soft := c.Query("soft") + var query *gorm.DB + if soft == "only" { + query = database.DB.Unscoped().Model(&models.Hero{}).Where("deleted_at IS NOT NULL") + } else if soft == "with" { + query = database.DB.Unscoped().Model(&models.Hero{}) + } else { + query = database.DB.Model(&models.Hero{}) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []models.Hero + if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "per_page": perPage}) +} + +// AdminGetHero godoc +// @Summary Admin: Get a hero by id +// @Description Return a single hero by id +// @Tags heroes +// @Security BearerAuth +// @Produce json +// @Param id path int true "Hero ID" +// @Success 200 {object} controllers.HeroResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/heroes/{id} [get] +func AdminGetHero(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var h models.Hero + if err := database.DB.Unscoped().First(&h, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": h}) +} + +// CreateHero godoc +// @Summary Admin: Create a hero +// @Description Create a new hero item (multipart/form-data) +// @Tags heroes +// @Security BearerAuth +// @Accept multipart/form-data +// @Produce json +// @Param color formData string true "Color" +// @Param title formData string false "Title" +// @Param text1 formData string false "Text1" +// @Param text2 formData string false "Text2" +// @Param text4 formData string false "Text4" +// @Param text5 formData string false "Text5" +// @Param is_active formData boolean false "Is Active" +// @Param width formData int false "Image width (frontend-provided)" +// @Param height formData int false "Image height (frontend-provided)" +// @Param quality formData int false "Image quality (frontend-provided)" +// @Param format formData string false "Image format (jpeg|png|webp) (frontend-provided)" +// @Param image formData file false "Image file" +// @Success 201 {object} controllers.HeroResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/heroes [post] +func CreateHero(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + // Parse form fields + color := c.PostForm("color") + if color == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "color is required"}) + return + } + title := c.PostForm("title") + text1 := c.PostForm("text1") + text2 := c.PostForm("text2") + text4 := c.PostForm("text4") + text5 := c.PostForm("text5") + isActive := true + if v := c.PostForm("is_active"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + isActive = b + } + } + // optional frontend-provided image metadata + var width, height, quality int + if w := c.PostForm("width"); w != "" { + if wi, err := strconv.Atoi(w); err == nil { + width = wi + } + } + if h := c.PostForm("height"); h != "" { + if hi, err := strconv.Atoi(h); err == nil { + height = hi + } + } + if q := c.PostForm("quality"); q != "" { + if qi, err := strconv.Atoi(q); err == nil { + quality = qi + } + } + format := c.PostForm("format") + + hero := models.Hero{ + Color: color, + Title: title, + Text1: text1, + Text2: text2, + Text4: text4, + Text5: text5, + IsActive: isActive, + Width: width, + Height: height, + Quality: quality, + Format: format, + } + + // handle file upload (no server-side image processing) + file, err := c.FormFile("image") + if err == nil { + // ensure uploads/heroes exists + uploadDir := filepath.Join("uploads", "heroes") + if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"}) + return + } + ext := filepath.Ext(file.Filename) + newName := fmt.Sprintf("hero-%d%s", time.Now().UnixNano(), ext) + destination := filepath.Join(uploadDir, newName) + if err := c.SaveUploadedFile(file, destination); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + hero.Image = "/uploads/heroes/" + newName + // do not attempt to decode/process image here; frontend provides metadata + // if format not provided, fallback to extension without dot + if heroFormat := format; heroFormat == "" { + if ext != "" { + hero.Format = ext[1:] + } + } + } + + if err := database.DB.Create(&hero).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": hero}) +} + +// UpdateHero godoc +// @Summary Admin: Update a hero +// @Description Update an existing hero (multipart/form-data) +// @Tags heroes +// @Security BearerAuth +// @Accept multipart/form-data +// @Produce json +// @Param id path int true "Hero ID" +// @Param color formData string false "Color" +// @Param title formData string false "Title" +// @Param text1 formData string false "Text1" +// @Param text2 formData string false "Text2" +// @Param text4 formData string false "Text4" +// @Param text5 formData string false "Text5" +// @Param is_active formData boolean false "Is Active" +// @Param width formData int false "Image width (frontend-provided)" +// @Param height formData int false "Image height (frontend-provided)" +// @Param quality formData int false "Image quality (frontend-provided)" +// @Param format formData string false "Image format (jpeg|png|webp) (frontend-provided)" +// @Param image formData file false "Image file" +// @Success 200 {object} controllers.HeroResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/heroes/{id} [put] +func UpdateHero(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var h models.Hero + if err := database.DB.First(&h, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // read form fields (if present) + if color := c.PostForm("color"); color != "" { + h.Color = color + } + if title := c.PostForm("title"); title != "" { + h.Title = title + } + if t := c.PostForm("text1"); t != "" { + h.Text1 = t + } + if t := c.PostForm("text2"); t != "" { + h.Text2 = t + } + if t := c.PostForm("text4"); t != "" { + h.Text4 = t + } + if t := c.PostForm("text5"); t != "" { + h.Text5 = t + } + if v := c.PostForm("is_active"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + h.IsActive = b + } + } + // optional frontend-provided image metadata + if w := c.PostForm("width"); w != "" { + if wi, err := strconv.Atoi(w); err == nil { + h.Width = wi + } + } + if hgt := c.PostForm("height"); hgt != "" { + if hi, err := strconv.Atoi(hgt); err == nil { + h.Height = hi + } + } + if q := c.PostForm("quality"); q != "" { + if qi, err := strconv.Atoi(q); err == nil { + h.Quality = qi + } + } + if fmtStr := c.PostForm("format"); fmtStr != "" { + h.Format = fmtStr + } + + // handle optional file upload (no server-side processing) + file, err := c.FormFile("image") + if err == nil { + // Save new file first + uploadDir := filepath.Join("uploads", "heroes") + if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create upload dir"}) + return + } + ext := filepath.Ext(file.Filename) + newName := fmt.Sprintf("hero-%d%s", time.Now().UnixNano(), ext) + destination := filepath.Join(uploadDir, newName) + if err := c.SaveUploadedFile(file, destination); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // If there was a previous image, attempt to remove it safely + prev := h.Image + if prev != "" { + // normalize and ensure it's inside uploads/ + prevPath := strings.TrimPrefix(prev, "/") + clean := filepath.Clean(prevPath) + // only remove files under uploads/ to avoid accidental deletions + if strings.HasPrefix(clean, "uploads"+string(os.PathSeparator)) { + _ = os.Remove(clean) // ignore error + } + } + + h.Image = "/uploads/heroes/" + newName + // if format not provided by frontend, fallback to extension + if h.Format == "" && ext != "" { + h.Format = ext[1:] + } + } + + if err := database.DB.Save(&h).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": h}) +} + +// DeleteHero godoc +// @Summary Admin: Delete a hero +// @Description Soft-delete a hero by ID +// @Tags heroes +// @Security BearerAuth +// @Produce json +// @Param id path int true "Hero ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/heroes/{id} [delete] +func DeleteHero(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var h models.Hero + if err := database.DB.First(&h, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := database.DB.Delete(&h).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // attempt to remove image file if present + if h.Image != "" { + imgPath := strings.TrimPrefix(h.Image, "/") + clean := filepath.Clean(imgPath) + if strings.HasPrefix(clean, "uploads"+string(os.PathSeparator)) { + _ = os.Remove(clean) + } + } + c.JSON(http.StatusOK, gin.H{"message": "hero deleted successfully", "id": h.ID}) +} + +// RestoreHero godoc +// @Summary Admin: Restore a soft-deleted hero +// @Description Restore a soft-deleted hero by ID +// @Tags heroes +// @Security BearerAuth +// @Produce json +// @Param id path int true "Hero ID" +// @Success 200 {object} controllers.HeroResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/heroes/{id}/restore [post] +func RestoreHero(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var h models.Hero + // Find soft-deleted record with Unscoped + if err := database.DB.Unscoped().Where("id = ?", id).First(&h).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // Clear deleted_at (restore) + if err := database.DB.Unscoped().Model(&models.Hero{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // Reload the record in normal scope to ensure DeletedAt is nil in struct + if err := database.DB.First(&h, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": h}) +} + +// ListHeroes godoc +// @Summary Public: List heroes +// @Description Return active heroes with pagination +// @Tags heroes +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Success 200 {object} controllers.HeroListResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/heroes [get] +func ListHeroes(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 100 { + perPage = 100 + } + offset := (page - 1) * perPage + + query := database.DB.Model(&models.Hero{}).Where("is_active = ?", true) + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []models.Hero + if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "per_page": perPage}) +} + +// GetHero godoc +// @Summary Public: Get a hero by id +// @Description Return a single hero by id +// @Tags heroes +// @Produce json +// @Param id path int true "Hero ID" +// @Success 200 {object} controllers.HeroResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/heroes/{id} [get] +func GetHero(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var h models.Hero + if err := database.DB.First(&h, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "hero not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": h}) +} diff --git a/app/controllers/SettingController.go b/app/controllers/SettingController.go new file mode 100644 index 0000000..584b950 --- /dev/null +++ b/app/controllers/SettingController.go @@ -0,0 +1,758 @@ +package controllers + +import ( + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + database "goGin/app/database/config" + "goGin/app/database/models" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// Payload for creating/updating settings +type SettingPayload struct { + Title string `json:"title" binding:"required"` + MetaTitle string `json:"meta_title" binding:"required"` + MetaDescription string `json:"meta_description" binding:"required"` + Phone string `json:"phone" binding:"required"` + URL string `json:"url" binding:"required"` + Email string `json:"email" binding:"required"` + Facebook string `json:"facebook"` + X string `json:"x"` + Instagram string `json:"instagram"` + Whatsapp string `json:"whatsapp"` + Pinterest string `json:"pinterest"` + Linkedin string `json:"linkedin"` + Slogan string `json:"slogan"` + Address string `json:"address"` + Copyright string `json:"copyright"` + MapEmbed string `json:"map_embed"` + WLogo string `json:"w_logo"` + BLogo string `json:"b_logo"` + IsActive *bool `json:"is_active"` + // Optional image transformation / dimension settings + WWidth *int `json:"w_width"` + WHeight *int `json:"w_height"` + WQuality *int `json:"w_quality"` + WFormat string `json:"w_format"` + BWidth *int `json:"b_width"` + BHeight *int `json:"b_height"` + BQuality *int `json:"b_quality"` + BFormat string `json:"b_format"` +} + +// AdminListSettings godoc +// @Summary Admin: List settings +// @Description Admin listing of settings. Use ?soft=only to list deleted, ?soft=with to include deleted. +// @Tags settings +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param soft query string false "Soft delete filter: only|with" +// @Success 200 {object} controllers.SettingListResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/settings [get] +func AdminListSettings(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 200 { + perPage = 200 + } + offset := (page - 1) * perPage + + soft := c.Query("soft") + var query *gorm.DB + if soft == "only" { + query = database.DB.Unscoped().Model(&models.Setting{}).Where("deleted_at IS NOT NULL") + } else if soft == "with" { + query = database.DB.Unscoped().Model(&models.Setting{}) + } else { + query = database.DB.Model(&models.Setting{}) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var items []models.Setting + if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&items).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"items": items, "total": total, "page": page, "per_page": perPage}) +} + +// AdminGetSetting godoc +// @Summary Admin: Get a setting by id +// @Description Return a single setting by id +// @Tags settings +// @Security BearerAuth +// @Produce json +// @Param id path int true "Setting ID" +// @Success 200 {object} controllers.SettingResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/settings/{id} [get] +func AdminGetSetting(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var s models.Setting + if err := database.DB.Unscoped().First(&s, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": s}) +} + +// AdminCreateSetting godoc +// @Summary Admin: Create a setting +// @Description Create a new setting +// @Tags settings +// @Security BearerAuth +// @Accept multipart/form-data +// @Produce json +// @Param title formData string true "Title" +// @Param meta_title formData string true "Meta title" +// @Param meta_description formData string true "Meta description" +// @Param phone formData string true "Phone" +// @Param url formData string true "URL" +// @Param email formData string true "Email" +// @Param facebook formData string false "Facebook" +// @Param x formData string false "X" +// @Param instagram formData string false "Instagram" +// @Param whatsapp formData string false "Whatsapp" +// @Param pinterest formData string false "Pinterest" +// @Param linkedin formData string false "Linkedin" +// @Param slogan formData string false "Slogan" +// @Param address formData string false "Address" +// @Param copyright formData string false "Copyright" +// @Param map_embed formData string false "Map embed" +// @Param w_logo formData file false "White logo file upload (or provide w_logo path as string)" +// @Param b_logo formData file false "Black logo file upload (or provide b_logo path as string)" +// @Param is_active formData boolean false "Is active" +// @Param w_width formData int false "W logo width" +// @Param w_height formData int false "W logo height" +// @Param w_quality formData int false "W logo quality" +// @Param w_format formData string false "W logo format" +// @Param b_width formData int false "B logo width" +// @Param b_height formData int false "B logo height" +// @Param b_quality formData int false "B logo quality" +// @Param b_format formData string false "B logo format" +// @Success 201 {object} controllers.SettingResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/settings [post] +func AdminCreateSetting(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + + // Support both JSON and multipart/form-data + var payload SettingPayload + contentType := c.GetHeader("Content-Type") + if strings.HasPrefix(contentType, "multipart/form-data") { + // read form fields + payload.Title = c.PostForm("title") + payload.MetaTitle = c.PostForm("meta_title") + payload.MetaDescription = c.PostForm("meta_description") + payload.Phone = c.PostForm("phone") + payload.URL = c.PostForm("url") + payload.Email = c.PostForm("email") + payload.Facebook = c.PostForm("facebook") + payload.X = c.PostForm("x") + payload.Instagram = c.PostForm("instagram") + payload.Whatsapp = c.PostForm("whatsapp") + payload.Pinterest = c.PostForm("pinterest") + payload.Linkedin = c.PostForm("linkedin") + payload.Slogan = c.PostForm("slogan") + payload.Address = c.PostForm("address") + payload.Copyright = c.PostForm("copyright") + payload.MapEmbed = c.PostForm("map_embed") + // keep payload.WLogo/BLogo as string if client sends path + payload.WLogo = c.PostForm("w_logo") + payload.BLogo = c.PostForm("b_logo") + if v := c.PostForm("is_active"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + payload.IsActive = &b + } + } + // numeric metadata + if v := c.PostForm("w_width"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + payload.WWidth = &n + } + } + if v := c.PostForm("w_height"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + payload.WHeight = &n + } + } + if v := c.PostForm("w_quality"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + payload.WQuality = &n + } + } + payload.WFormat = c.PostForm("w_format") + if v := c.PostForm("b_width"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + payload.BWidth = &n + } + } + if v := c.PostForm("b_height"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + payload.BHeight = &n + } + } + if v := c.PostForm("b_quality"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + payload.BQuality = &n + } + } + payload.BFormat = c.PostForm("b_format") + } else { + // JSON + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + + // basic required validation + if payload.Title == "" || payload.MetaTitle == "" || payload.MetaDescription == "" || payload.Phone == "" || payload.URL == "" || payload.Email == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing required fields"}) + return + } + + isActive := false + if payload.IsActive != nil { + isActive = *payload.IsActive + } + setting := models.Setting{ + Title: payload.Title, + MetaTitle: payload.MetaTitle, + MetaDescription: payload.MetaDescription, + Phone: payload.Phone, + URL: payload.URL, + Email: payload.Email, + Facebook: payload.Facebook, + X: payload.X, + Instagram: payload.Instagram, + Whatsapp: payload.Whatsapp, + Pinterest: payload.Pinterest, + Linkedin: payload.Linkedin, + Slogan: payload.Slogan, + Address: payload.Address, + Copyright: payload.Copyright, + MapEmbed: payload.MapEmbed, + WLogo: payload.WLogo, + BLogo: payload.BLogo, + IsActive: isActive, + } + // optional image transform params + if payload.WWidth != nil { + setting.WWidth = *payload.WWidth + } + if payload.WHeight != nil { + setting.WHeight = *payload.WHeight + } + if payload.WQuality != nil { + setting.WQuality = *payload.WQuality + } + setting.WFormat = payload.WFormat + if payload.BWidth != nil { + setting.BWidth = *payload.BWidth + } + if payload.BHeight != nil { + setting.BHeight = *payload.BHeight + } + if payload.BQuality != nil { + setting.BQuality = *payload.BQuality + } + setting.BFormat = payload.BFormat + + // Handle optional logo file uploads when multipart/form-data + if strings.HasPrefix(contentType, "multipart/form-data") { + // Support file upload on field name 'w_logo' (preferred) or fallback to provided path + if file, err := c.FormFile("w_logo"); err == nil { + uploadDir := filepath.Join("uploads", "logos") + _ = os.MkdirAll(uploadDir, os.ModePerm) + ext := filepath.Ext(file.Filename) + newName := "wlogo-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ext + destination := filepath.Join(uploadDir, newName) + if err := c.SaveUploadedFile(file, destination); err == nil { + setting.WLogo = "/uploads/logos/" + newName + if setting.WFormat == "" && ext != "" { + setting.WFormat = ext[1:] + } + } + } + // Support file upload on field name 'b_logo' + if file, err := c.FormFile("b_logo"); err == nil { + uploadDir := filepath.Join("uploads", "logos") + _ = os.MkdirAll(uploadDir, os.ModePerm) + ext := filepath.Ext(file.Filename) + newName := "blogo-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ext + destination := filepath.Join(uploadDir, newName) + if err := c.SaveUploadedFile(file, destination); err == nil { + setting.BLogo = "/uploads/logos/" + newName + if setting.BFormat == "" && ext != "" { + setting.BFormat = ext[1:] + } + } + } + } + + // Enforce single active setting rule + if setting.IsActive { + // Deactivate all other settings + if err := database.DB.Model(&models.Setting{}).Where("1 = 1").Update("is_active", false).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate other settings: " + err.Error()}) + return + } + } + + if err := database.DB.Create(&setting).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, gin.H{"data": setting}) +} + +// AdminUpdateSetting godoc +// @Summary Admin: Update a setting +// @Description Update an existing setting +// @Tags settings +// @Security BearerAuth +// @Accept multipart/form-data +// @Produce json +// @Param id path int true "Setting ID" +// @Param title formData string false "Title" +// @Param meta_title formData string false "Meta title" +// @Param meta_description formData string false "Meta description" +// @Param phone formData string false "Phone" +// @Param url formData string false "URL" +// @Param email formData string false "Email" +// @Param facebook formData string false "Facebook" +// @Param x formData string false "X" +// @Param instagram formData string false "Instagram" +// @Param whatsapp formData string false "Whatsapp" +// @Param pinterest formData string false "Pinterest" +// @Param linkedin formData string false "Linkedin" +// @Param slogan formData string false "Slogan" +// @Param address formData string false "Address" +// @Param copyright formData string false "Copyright" +// @Param map_embed formData string false "Map embed" +// @Param w_logo formData file false "White logo file upload (or provide w_logo path as string)" +// @Param b_logo formData file false "Black logo file upload (or provide b_logo path as string)" +// @Param is_active formData boolean false "Is active" +// @Param w_width formData int false "W logo width" +// @Param w_height formData int false "W logo height" +// @Param w_quality formData int false "W logo quality" +// @Param w_format formData string false "W logo format" +// @Param b_width formData int false "B logo width" +// @Param b_height formData int false "B logo height" +// @Param b_quality formData int false "B logo quality" +// @Param b_format formData string false "B logo format" +// @Success 200 {object} controllers.SettingResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/settings/{id} [put] +func AdminUpdateSetting(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var s models.Setting + if err := database.DB.First(&s, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + contentType := c.GetHeader("Content-Type") + if strings.HasPrefix(contentType, "multipart/form-data") { + // read form fields and update if present + if v := c.PostForm("title"); v != "" { + s.Title = v + } + if v := c.PostForm("meta_title"); v != "" { + s.MetaTitle = v + } + if v := c.PostForm("meta_description"); v != "" { + s.MetaDescription = v + } + if v := c.PostForm("phone"); v != "" { + s.Phone = v + } + if v := c.PostForm("url"); v != "" { + s.URL = v + } + if v := c.PostForm("email"); v != "" { + s.Email = v + } + if v := c.PostForm("facebook"); v != "" { + s.Facebook = v + } + if v := c.PostForm("x"); v != "" { + s.X = v + } + if v := c.PostForm("instagram"); v != "" { + s.Instagram = v + } + if v := c.PostForm("whatsapp"); v != "" { + s.Whatsapp = v + } + if v := c.PostForm("pinterest"); v != "" { + s.Pinterest = v + } + if v := c.PostForm("linkedin"); v != "" { + s.Linkedin = v + } + if v := c.PostForm("slogan"); v != "" { + s.Slogan = v + } + if v := c.PostForm("address"); v != "" { + s.Address = v + } + if v := c.PostForm("copyright"); v != "" { + s.Copyright = v + } + if v := c.PostForm("map_embed"); v != "" { + s.MapEmbed = v + } + if v := c.PostForm("w_logo"); v != "" { + s.WLogo = v + } + if v := c.PostForm("b_logo"); v != "" { + s.BLogo = v + } + if v := c.PostForm("is_active"); v != "" { + if b, err := strconv.ParseBool(v); err == nil { + s.IsActive = b + } + } + if v := c.PostForm("w_width"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + s.WWidth = n + } + } + if v := c.PostForm("w_height"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + s.WHeight = n + } + } + if v := c.PostForm("w_quality"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + s.WQuality = n + } + } + if v := c.PostForm("w_format"); v != "" { + s.WFormat = v + } + if v := c.PostForm("b_width"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + s.BWidth = n + } + } + if v := c.PostForm("b_height"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + s.BHeight = n + } + } + if v := c.PostForm("b_quality"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + s.BQuality = n + } + } + if v := c.PostForm("b_format"); v != "" { + s.BFormat = v + } + + // Handle optional file uploads + if file, err := c.FormFile("w_logo"); err == nil { + uploadDir := filepath.Join("uploads", "logos") + _ = os.MkdirAll(uploadDir, os.ModePerm) + ext := filepath.Ext(file.Filename) + newName := "wlogo-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ext + destination := filepath.Join(uploadDir, newName) + if err := c.SaveUploadedFile(file, destination); err == nil { + s.WLogo = "/uploads/logos/" + newName + if s.WFormat == "" && ext != "" { + s.WFormat = ext[1:] + } + } + } + if file, err := c.FormFile("b_logo"); err == nil { + uploadDir := filepath.Join("uploads", "logos") + _ = os.MkdirAll(uploadDir, os.ModePerm) + ext := filepath.Ext(file.Filename) + newName := "blogo-" + strconv.FormatInt(time.Now().UnixNano(), 10) + ext + destination := filepath.Join(uploadDir, newName) + if err := c.SaveUploadedFile(file, destination); err == nil { + s.BLogo = "/uploads/logos/" + newName + if s.BFormat == "" && ext != "" { + s.BFormat = ext[1:] + } + } + } + } else { + // JSON payload + var payload SettingPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + // update fields from payload + s.Title = payload.Title + s.MetaTitle = payload.MetaTitle + s.MetaDescription = payload.MetaDescription + s.Phone = payload.Phone + s.URL = payload.URL + s.Email = payload.Email + s.Facebook = payload.Facebook + s.X = payload.X + s.Instagram = payload.Instagram + s.Whatsapp = payload.Whatsapp + s.Pinterest = payload.Pinterest + s.Linkedin = payload.Linkedin + s.Slogan = payload.Slogan + s.Address = payload.Address + s.Copyright = payload.Copyright + s.MapEmbed = payload.MapEmbed + s.WLogo = payload.WLogo + s.BLogo = payload.BLogo + if payload.IsActive != nil { + s.IsActive = *payload.IsActive + } + if payload.WWidth != nil { + s.WWidth = *payload.WWidth + } + if payload.WHeight != nil { + s.WHeight = *payload.WHeight + } + if payload.WQuality != nil { + s.WQuality = *payload.WQuality + } + s.WFormat = payload.WFormat + if payload.BWidth != nil { + s.BWidth = *payload.BWidth + } + if payload.BHeight != nil { + s.BHeight = *payload.BHeight + } + if payload.BQuality != nil { + s.BQuality = *payload.BQuality + } + s.BFormat = payload.BFormat + } + + // Enforce single active setting rule + if s.IsActive { + // Deactivate all other settings except this one + if err := database.DB.Model(&models.Setting{}).Where("id != ?", s.ID).Update("is_active", false).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate other settings: " + err.Error()}) + return + } + } + + if err := database.DB.Save(&s).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"data": s}) +} + +// AdminDeleteSetting godoc +// @Summary Admin: Delete a setting +// @Description Soft-delete a setting by ID +// @Tags settings +// @Security BearerAuth +// @Produce json +// @Param id path int true "Setting ID" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/settings/{id} [delete] +func AdminDeleteSetting(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var s models.Setting + if err := database.DB.First(&s, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if err := database.DB.Delete(&s).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // attempt to remove logo files if present (safe: only under uploads/) + for _, p := range []string{s.WLogo, s.BLogo} { + if p == "" { + continue + } + imgPath := strings.TrimPrefix(p, "/") + clean := filepath.Clean(imgPath) + if strings.HasPrefix(clean, "uploads"+string(os.PathSeparator)) { + _ = os.Remove(clean) + } + } + c.JSON(http.StatusOK, gin.H{"message": "setting deleted successfully", "id": s.ID}) +} + +// AdminRestoreSetting godoc +// @Summary Admin: Restore a soft-deleted setting +// @Description Restore a soft-deleted setting by ID +// @Tags settings +// @Security BearerAuth +// @Produce json +// @Param id path int true "Setting ID" +// @Success 200 {object} controllers.SettingResponse +// @Failure 400 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/settings/{id}/restore [post] +func AdminRestoreSetting(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + var s models.Setting + // Find soft-deleted record using Unscoped + if err := database.DB.Unscoped().Where("id = ?", id).First(&s).Error; err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, gin.H{"error": "setting not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // If DeletedAt is zero, record is not soft-deleted + if s.DeletedAt.Time.IsZero() { + c.JSON(http.StatusBadRequest, gin.H{"error": "setting is not deleted"}) + return + } + // Clear deleted_at (restore) using Unscoped Model to allow update on soft-deleted rows + res := database.DB.Unscoped().Model(&models.Setting{}).Where("id = ?", id).UpdateColumn("deleted_at", nil) + if res.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": res.Error.Error()}) + return + } + if res.RowsAffected == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "restore failed (no rows affected)"}) + return + } + // Reload the record in normal scope to ensure DeletedAt is nil in struct + if err := database.DB.First(&s, id).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Enforce single active setting rule if restored setting is active + if s.IsActive { + if err := database.DB.Model(&models.Setting{}).Where("id != ?", s.ID).Update("is_active", false).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate other settings: " + err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"data": s}) +} + +// GetSettings godoc +// @Summary Public: Get site settings +// @Description Return the active site setting (latest active). If none active, return latest setting. +// @Tags settings +// @Produce json +// @Success 200 {object} controllers.SettingResponse +// @Failure 500 {object} map[string]string +// @Router /api/v1/settings [get] +func GetSettings(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + var s models.Setting + // Try to find active setting + if err := database.DB.Where("is_active = ?", true).Order("updated_at desc").First(&s).Error; err != nil { + // if not found, fallback to latest + if err == gorm.ErrRecordNotFound { + if err2 := database.DB.Order("updated_at desc").First(&s).Error; err2 != nil { + if err2 == gorm.ErrRecordNotFound { + c.JSON(http.StatusOK, gin.H{"data": nil}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err2.Error()}) + return + } + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + c.JSON(http.StatusOK, gin.H{"data": s}) +} diff --git a/app/controllers/UserControllers.go b/app/controllers/UserControllers.go new file mode 100644 index 0000000..eff3752 --- /dev/null +++ b/app/controllers/UserControllers.go @@ -0,0 +1,374 @@ +package controllers + +import ( + "net/http" + "strconv" + "time" + + database "goGin/app/database/config" + "goGin/app/database/models" + "goGin/app/middlewares" + utils "goGin/pkg/utis" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// UserResponse, kullanıcı verilerini güvenli bir şekilde döndürmek için +type UserResponse struct { + ID uint `json:"id"` + UserName string `json:"username"` + Email string `json:"email"` + EmailVerified bool `json:"email_verified"` + IsAdmin bool `json:"is_admin"` +} + +// AdminUserListItem, admin listesinde deleted_at ile ayırt etmek için +type AdminUserListItem struct { + UserResponse + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +// UserPayload, kullanıcı güncelleme payload'u +type UserPayload struct { + UserName string `json:"username"` + Email string `json:"email"` + Password string `json:"password,omitempty"` // Opsiyonel şifre güncellemesi +} + +// AdminUserUpdatePayload, admin tarafından kullanıcı güncelleme +type AdminUserUpdatePayload struct { + UserName string `json:"username"` + Email string `json:"email"` + IsAdmin *bool `json:"is_admin"` // Pointer allows checking if field is present +} + +// Helper to convert model to response +func toUserResponse(u models.User) UserResponse { + isAdmin := false + if u.IsAdmin != nil { + isAdmin = *u.IsAdmin + } + isVerified := false + if u.EmailVerified != nil { + isVerified = *u.EmailVerified + } + + return UserResponse{ + ID: u.ID, + UserName: u.UserName, + Email: u.Email, + EmailVerified: isVerified, + IsAdmin: isAdmin, + } +} + +// toAdminUserListItem, admin listesinde deleted_at döndürmek için +func toAdminUserListItem(u models.User) AdminUserListItem { + item := AdminUserListItem{UserResponse: toUserResponse(u)} + if u.DeletedAt.Valid { + item.DeletedAt = &u.DeletedAt.Time + } + return item +} + +// GetProfile godoc +// @Summary Get current user profile +// @Description Get profile of the logged-in user +// @Tags users +// @Security BearerAuth +// @Produce json +// @Success 200 {object} controllers.UserResponse +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/users/profile [get] +func GetProfile(c *gin.Context) { + claims, ok := middlewares.GetAuthClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + var user models.User + if err := database.DB.Preload("SocialAccounts").Preload("Profile").First(&user, claims.UserID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)}) +} + +// UpdateProfile godoc +// @Summary Update current user profile +// @Description Update profile of the logged-in user +// @Tags users +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param user body UserPayload true "User update payload" +// @Success 200 {object} controllers.UserResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/users/profile [put] +func UpdateProfile(c *gin.Context) { + claims, ok := middlewares.GetAuthClaims(c) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + + var payload UserPayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := database.DB.First(&user, claims.UserID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + if payload.UserName != "" { + user.UserName = payload.UserName + } + if payload.Email != "" { + user.Email = payload.Email + // Email değişirse doğrulama sıfırlanabilir + f := false + user.EmailVerified = &f + } + if payload.Password != "" { + hashed, err := utils.HashPassword(payload.Password) + if err == nil { + user.Password = hashed + } + } + + if err := database.DB.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)}) +} + +// AdminListUsers godoc +// @Summary Admin: List users +// @Description Admin listing of users with pagination and search +// @Tags users_admin +// @Security BearerAuth +// @Produce json +// @Param page query int false "Page number" +// @Param per_page query int false "Items per page" +// @Param q query string false "Search query (username or email)" +// @Param soft query string false "Soft delete filter: only|with" +// @Success 200 {object} map[string]interface{} +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/users [get] +func AdminListUsers(c *gin.Context) { + if database.DB == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "database not configured"}) + return + } + pageStr := c.DefaultQuery("page", "1") + perPageStr := c.DefaultQuery("per_page", "20") + page, _ := strconv.Atoi(pageStr) + perPage, _ := strconv.Atoi(perPageStr) + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 20 + } + if perPage > 100 { + perPage = 100 + } + offset := (page - 1) * perPage + + soft := c.Query("soft") + var query *gorm.DB + if soft == "only" { + query = database.DB.Unscoped().Model(&models.User{}).Where("deleted_at IS NOT NULL") + } else if soft == "with" { + query = database.DB.Unscoped().Model(&models.User{}) + } else { + query = database.DB.Model(&models.User{}) + } + + if q := c.Query("q"); q != "" { + like := "%" + q + "%" + query = query.Where("user_name LIKE ? OR email LIKE ?", like, like) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var users []models.User + if err := query.Order("created_at desc").Limit(perPage).Offset(offset).Find(&users).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + var data []AdminUserListItem + for _, u := range users { + data = append(data, toAdminUserListItem(u)) + } + + c.JSON(http.StatusOK, gin.H{"items": data, "total": total, "page": page, "per_page": perPage}) +} + +// AdminGetUser godoc +// @Summary Admin: Get user +// @Description Get user details by ID +// @Tags users_admin +// @Security BearerAuth +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} controllers.UserResponse +// @Failure 404 {object} map[string]string +// @Router /api/v1/admin/users/{id} [get] +func AdminGetUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var user models.User + // Admin deleted kullanıcıyı da görebilmeli mi? Genelde evet, soft=with ile listede görüyorsa detayda da görmeli. + // Varsayılan olarak normal get soft-deleted getirmez. Unscoped kullanalım veya id ile direk bakalım. + if err := database.DB.Unscoped().First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)}) +} + +// AdminUpdateUser godoc +// @Summary Admin: Update user +// @Description Update user details (admin) +// @Tags users_admin +// @Security BearerAuth +// @Accept json +// @Produce json +// @Param id path int true "User ID" +// @Param user body AdminUserUpdatePayload true "User update payload" +// @Success 200 {object} controllers.UserResponse +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/users/{id} [put] +func AdminUpdateUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var payload AdminUserUpdatePayload + if err := c.ShouldBindJSON(&payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user models.User + if err := database.DB.Unscoped().First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + if payload.UserName != "" { + user.UserName = payload.UserName + } + if payload.Email != "" { + user.Email = payload.Email + } + if payload.IsAdmin != nil { + user.IsAdmin = payload.IsAdmin + } + + if err := database.DB.Save(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)}) +} + +// AdminDeleteUser godoc +// @Summary Admin: Delete user +// @Description Soft delete user +// @Tags users_admin +// @Security BearerAuth +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/users/{id} [delete] +func AdminDeleteUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var user models.User + if err := database.DB.First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + if err := database.DB.Delete(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "message": "user deleted successfully", + "id": user.ID, + }) +} + +// AdminRestoreUser godoc +// @Summary Admin: Restore user +// @Description Restore soft-deleted user +// @Tags users_admin +// @Security BearerAuth +// @Produce json +// @Param id path int true "User ID" +// @Success 200 {object} controllers.UserResponse +// @Failure 404 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /api/v1/admin/users/{id}/restore [post] +func AdminRestoreUser(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.Atoi(idStr) + if err != nil || id < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"}) + return + } + + var user models.User + if err := database.DB.Unscoped().First(&user, id).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + + if user.DeletedAt.Valid { + // Restore + if err := database.DB.Unscoped().Model(&user).Update("deleted_at", nil).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{"data": toUserResponse(user)}) +} diff --git a/app/controllers/swagger_models.go b/app/controllers/swagger_models.go new file mode 100644 index 0000000..ea56e92 --- /dev/null +++ b/app/controllers/swagger_models.go @@ -0,0 +1,137 @@ +package controllers + +import "time" + +// Note: these structs are only used for Swagger documentation generation. +// They intentionally avoid embedding gorm.Model to keep swag parser happy. + +type CategorySimple struct { + ID uint `json:"id" example:"1"` + Title string `json:"title" example:"News"` + Slug string `json:"slug" example:"news"` + ParentID *uint `json:"parent_id,omitempty"` +} + +// AdminCategoryListItem admin listesinde deleted_at ile ayırt etmek için +type AdminCategoryListItem struct { + ID uint `json:"id" example:"1"` + Title string `json:"title" example:"News"` + Slug string `json:"slug" example:"news"` + ParentID *uint `json:"parent_id,omitempty"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +type TagSimple struct { + ID uint `json:"id" example:"1"` + Name string `json:"name" example:"golang"` +} + +// AdminTagListItem admin listesinde deleted_at ile ayırt etmek için +type AdminTagListItem struct { + ID uint `json:"id" example:"1"` + Name string `json:"name" example:"golang"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +type PostResponse struct { + ID uint `json:"id" example:"1"` + Title string `json:"title" example:"My post title"` + Slug string `json:"slug" example:"my-post-title"` + Images string `json:"images"` + Content string `json:"content"` + Categories []CategorySimple `json:"categories,omitempty"` + Tags []TagSimple `json:"tags,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type PostListResponse struct { + Items []PostResponse `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +// New swagger-only types +type CommentSimple struct { + ID uint `json:"id" example:"1"` + UserID uint `json:"user_id" example:"2"` + PostID uint `json:"post_id" example:"1"` + Body string `json:"body" example:"Nice post"` + Created time.Time `json:"created_at"` +} + +type CategoryViewSimple struct { + ID uint `json:"id" example:"1"` + CategoryID uint `json:"category_id" example:"1"` + IPAddress string `json:"ip_address" example:"127.0.0.1"` + Created time.Time `json:"created_at"` +} + +// Setting swagger-only types +type SettingResponse struct { + ID uint `json:"id" example:"1"` + Title string `json:"title" example:"Site Başlığı"` + MetaTitle string `json:"meta_title" example:"Meta Başlık"` + MetaDescription string `json:"meta_description" example:"Site açıklaması"` + Phone string `json:"phone" example:" +90 555 555 55 55"` + URL string `json:"url" example:"https://example.com"` + Email string `json:"email" example:"info@example.com"` + Facebook string `json:"facebook" example:"https://facebook.com/example"` + X string `json:"x" example:"https://x.com/example"` + Instagram string `json:"instagram" example:"https://instagram.com/example"` + Whatsapp string `json:"whatsapp" example:"https://wa.me/90555"` + Pinterest string `json:"pinterest" example:"https://pinterest.com/example"` + Linkedin string `json:"linkedin" example:"https://linkedin.com/company/example"` + Slogan string `json:"slogan" example:"En iyi içerik"` + Address string `json:"address" example:"Adres örneği"` + Copyright string `json:"copyright" example:"© 2026 Example"` + MapEmbed string `json:"map_embed"` + WLogo string `json:"w_logo"` + BLogo string `json:"b_logo"` + IsActive bool `json:"is_active"` + // image transform / metadata fields (match app/database/models/setting.go) + WWidth int `json:"w_width"` + WHeight int `json:"w_height"` + WQuality int `json:"w_quality"` + WFormat string `json:"w_format"` + BWidth int `json:"b_width"` + BHeight int `json:"b_height"` + BQuality int `json:"b_quality"` + BFormat string `json:"b_format"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SettingListResponse struct { + Items []SettingResponse `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +// Hero swagger-only types +type HeroResponse struct { + ID uint `json:"id" example:"1"` + Color string `json:"color" example:"#ffffff"` + Title string `json:"title" example:"Hero Başlık"` + Text1 string `json:"text1" example:"Kısa açıklama"` + Text2 string `json:"text2" example:"İkinci metin"` + Text4 string `json:"text4" example:"Yardımcı metin"` + Text5 string `json:"text5" example:"Ek metin"` + Image string `json:"image" example:"/uploads/heroes/img.jpg"` + IsActive bool `json:"is_active"` + Width int `json:"width" example:"1920"` + Height int `json:"height" example:"1080"` + Quality int `json:"quality" example:"80"` + Format string `json:"format" example:"jpeg"` + Created time.Time `json:"created_at"` + Updated time.Time `json:"updated_at"` +} + +type HeroListResponse struct { + Items []HeroResponse `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PerPage int `json:"per_page"` +} diff --git a/app/database/config/mysql_db.go b/app/database/config/mysql_db.go new file mode 100644 index 0000000..b1b5bfd --- /dev/null +++ b/app/database/config/mysql_db.go @@ -0,0 +1,39 @@ +package database + +import ( + configs "goGin/config" + "log" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var DB *gorm.DB + +func ConnectDB() { + dsn := configs.AppConfig.DBUrl + if dsn == "" { + log.Println(".env dosyasında DB_URL ayarlı değil — veritabanı bağlantısı atlanıyor (geliştirme modu)") + return + } + + log.Println("Yapılandırmada DB_URL bulundu, veritabanına bağlanılmaya çalışılıyor...") + + // GORM için MySQL konfigürasyonu + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Info), // Info seviyesi (performans etkileyebilir); üretimde Error seviyesine alınabilir + PrepareStmt: true, // PrepareStmt performansını artırmak için + NowFunc: func() time.Time { + return time.Now().UTC() + }, + }) + if err != nil { + log.Println("MySQL veritabanı bağlantısı kurulamadı:", err) + return + } + + log.Println("MySQL bağlantısı Sağlandı.") + DB = db +} diff --git a/app/database/config/redis_db.go b/app/database/config/redis_db.go new file mode 100644 index 0000000..1f73446 --- /dev/null +++ b/app/database/config/redis_db.go @@ -0,0 +1,108 @@ +package database + +import ( + "context" + "github.com/redis/go-redis/v9" + config "goGin/config" + "log" + "time" +) + +var RedisClient *redis.Client +var RedisOptions *redis.Options +var ctx = context.Background() + +func ConnectRedis() { + redisURL := config.AppConfig.RedisUrl + if redisURL == "" { + log.Println("Warning: REDIS_URL is not set, continuing without Redis cache") + return + } + + opt, err := redis.ParseURL(redisURL) + if err != nil { + log.Printf("Warning: Failed to parse Redis URL: %v, continuing without Redis cache", err) + RedisOptions = nil + return + } + + RedisOptions = opt + RedisClient = redis.NewClient(opt) + + // Test connection + _, err = RedisClient.Ping(ctx).Result() + if err != nil { + log.Printf("Warning: Failed to connect to Redis: %v, continuing without Redis cache", err) + RedisClient = nil + RedisOptions = nil + return + } + + log.Println("Redis Bağlatısı Sağlandı") +} + +// Set stores a key-value pair in Redis with expiration +func Set(key string, value interface{}, expiration time.Duration) error { + if RedisClient == nil { + return nil // Gracefully handle when Redis is not available + } + return RedisClient.Set(ctx, key, value, expiration).Err() +} + +// Get retrieves a value from Redis +func Get(key string) (string, error) { + if RedisClient == nil { + return "", redis.Nil // Return Nil error when Redis is not available + } + return RedisClient.Get(ctx, key).Result() +} + +// Delete removes a key from Redis +func Delete(key string) error { + if RedisClient == nil { + return nil + } + return RedisClient.Del(ctx, key).Err() +} + +// Exists checks if a key exists in Redis +func Exists(key string) (bool, error) { + if RedisClient == nil { + return false, nil + } + count, err := RedisClient.Exists(ctx, key).Result() + return count > 0, err +} + +// SetWithJSON stores a JSON-serializable value in Redis +func SetEx(key string, value interface{}, seconds int) error { + if RedisClient == nil { + return nil + } + return RedisClient.Set(ctx, key, value, time.Duration(seconds)*time.Second).Err() +} + +// Increment increments a counter in Redis +func Increment(key string) (int64, error) { + if RedisClient == nil { + return 0, nil + } + return RedisClient.Incr(ctx, key).Result() +} + +// Expire sets expiration time for a key +func Expire(key string, expiration time.Duration) error { + if RedisClient == nil { + return nil + } + return RedisClient.Expire(ctx, key, expiration).Err() +} + +// FlushAll clears all keys in the current database +func FlushAll() error { + if RedisClient == nil { + return nil + } + log.Println("🧹 Clearing Redis Cache...") + return RedisClient.FlushDB(ctx).Err() +} diff --git a/app/database/migrate/migrate.go b/app/database/migrate/migrate.go new file mode 100644 index 0000000..2d824ec --- /dev/null +++ b/app/database/migrate/migrate.go @@ -0,0 +1,122 @@ +package migrasyon + +import ( + database "goGin/app/database/config" + "goGin/app/database/models" + configs "goGin/config" + "log" + "net/url" + "strings" +) + +// Only run AutoMigrate if DB is initialized + +func Migrate() { + if database.DB != nil { + if err := database.DB.AutoMigrate( + &models.User{}, + &models.SocialAccount{}, + &models.Profile{}, + &models.Hero{}, + &models.Setting{}, + &models.CorsWhitelist{}, + &models.CorsBlacklist{}, + &models.RateLimitSetting{}, + &models.Category{}, + &models.Tag{}, + &models.Post{}, + &models.CategoryView{}, + &models.Comment{}, + ); err != nil { + log.Printf("AutoMigrate Yapılamadı !!: %v", err) + } + seedSecurityDefaults() + log.Println("AutoMigrate Yapıldı.") + } else { + log.Println("DB not initialized: skipping AutoMigrate") + } +} + +func seedSecurityDefaults() { + seedRateLimit("register", "Register endpoint default rate limit", 5, 60) + seedRateLimit("login", "Login endpoint default rate limit", 10, 60) + + for _, origin := range defaultWhitelistOrigins() { + seedCorsWhitelist(origin, "default seeded whitelist") + } +} + +func seedRateLimit(name, description string, maxRequests int64, windowSeconds int) { + var existing models.RateLimitSetting + if err := database.DB.Where("name = ?", name).First(&existing).Error; err == nil { + return + } + + item := models.RateLimitSetting{ + Name: name, + Description: description, + MaxRequests: maxRequests, + WindowSeconds: windowSeconds, + IsActive: true, + UpdatedBy: "seed", + } + if err := database.DB.Create(&item).Error; err != nil { + log.Printf("RateLimit seed failed (%s): %v", name, err) + return + } + log.Printf("RateLimit seed created: name=%s max=%d window=%ds", name, maxRequests, windowSeconds) +} + +func seedCorsWhitelist(origin, description string) { + origin = strings.TrimSpace(origin) + if origin == "" { + return + } + + var existing models.CorsWhitelist + if err := database.DB.Where("origin = ?", origin).First(&existing).Error; err == nil { + return + } + + item := models.CorsWhitelist{ + Origin: origin, + Description: description, + IsActive: true, + CreatedBy: "seed", + } + if err := database.DB.Create(&item).Error; err != nil { + log.Printf("CorsWhitelist seed failed (%s): %v", origin, err) + return + } + log.Printf("CorsWhitelist seed created: origin=%s", origin) +} + +func defaultWhitelistOrigins() []string { + origins := []string{ + "http://localhost:3000", + "http://localhost:5173", + "http://localhost:8080", + } + + appURL := strings.TrimSpace(configs.AppConfig.AppURL) + if appURL != "" { + if parsed, err := url.Parse(appURL); err == nil && parsed.Scheme != "" && parsed.Host != "" { + origins = append(origins, parsed.Scheme+"://"+parsed.Host) + } + } + + uniq := make(map[string]struct{}) + out := make([]string, 0, len(origins)) + for _, origin := range origins { + origin = strings.TrimSpace(origin) + if origin == "" { + continue + } + if _, ok := uniq[origin]; ok { + continue + } + uniq[origin] = struct{}{} + out = append(out, origin) + } + return out +} diff --git a/app/database/models/blog.go b/app/database/models/blog.go new file mode 100644 index 0000000..2477505 --- /dev/null +++ b/app/database/models/blog.go @@ -0,0 +1,51 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Minimal, temiz GORM modelleri + +type Category struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title"` + Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug"` + Description string `json:"description,omitempty"` + ParentID *uint `json:"parent_id,omitempty"` + Parent *Category `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;foreignKey:ParentID" json:"parent,omitempty"` + Children []Category `gorm:"foreignKey:ParentID" json:"children,omitempty"` + Posts []Post `gorm:"many2many:post_categories;" json:"posts,omitempty"` +} + +type Tag struct { + gorm.Model + Name string `gorm:"type:varchar(254);not null" json:"name"` + Posts []Post `gorm:"many2many:post_tags;" json:"posts,omitempty"` +} + +type Post struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title"` + Images string `gorm:"type:text;not null" json:"images"` + Content string `gorm:"type:text" json:"content,omitempty"` + Slug string `gorm:"type:varchar(254);not null;uniqueIndex" json:"slug"` + Categories []Category `gorm:"many2many:post_categories;" json:"categories,omitempty"` + Tags []Tag `gorm:"many2many:post_tags;" json:"tags,omitempty"` + Width int `gorm:"default:0" json:"width" form:"width"` + Height int `gorm:"default:0" json:"height" form:"height"` + Quality int `gorm:"default:0" json:"quality" form:"quality"` + Format string `gorm:"type:varchar(10)" json:"format" form:"format"` +} + +type CategoryView struct { + gorm.Model + CategoryID uint `json:"category_id"` + IPAddress string `gorm:"type:varchar(45)" json:"ip_address,omitempty"` +} + +type Comment struct { + gorm.Model + UserID uint `json:"user_id"` + PostID uint `json:"post_id"` + Body string `gorm:"type:text" json:"body,omitempty"` +} diff --git a/app/database/models/cors.go b/app/database/models/cors.go new file mode 100644 index 0000000..d3faaf0 --- /dev/null +++ b/app/database/models/cors.go @@ -0,0 +1,34 @@ +package models + +import ( + "gorm.io/gorm" +) + +// CorsWhitelist - CORS için izin verilen origin'ler +type CorsWhitelist struct { + gorm.Model + Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"` + Description string `gorm:"type:varchar(255)" json:"description"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"` +} + +// CorsBlacklist - CORS için yasaklanan origin'ler +type CorsBlacklist struct { + gorm.Model + Origin string `gorm:"type:varchar(255);uniqueIndex;not null" json:"origin"` + Reason string `gorm:"type:varchar(255)" json:"reason"` + IsActive bool `gorm:"default:true" json:"is_active"` + CreatedBy string `gorm:"type:varchar(255)" json:"created_by,omitempty"` +} + +// RateLimitSetting - Rate limit ayarları +type RateLimitSetting struct { + gorm.Model + Name string `gorm:"type:varchar(100);uniqueIndex;not null" json:"name"` // e.g., "login", "register", "api" + Description string `gorm:"type:varchar(255)" json:"description"` + MaxRequests int64 `gorm:"not null" json:"max_requests"` // Max istek sayısı + WindowSeconds int `gorm:"not null" json:"window_seconds"` // Zaman penceresi (saniye) + IsActive bool `gorm:"default:true" json:"is_active"` + UpdatedBy string `gorm:"type:varchar(255)" json:"updated_by,omitempty"` +} diff --git a/app/database/models/hero.go b/app/database/models/hero.go new file mode 100644 index 0000000..314a3df --- /dev/null +++ b/app/database/models/hero.go @@ -0,0 +1,23 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Banner model structure +// Represents a banner item with optional thumbnail. +type Hero struct { + gorm.Model + Color string `gorm:"type:varchar(32);not null" json:"color" form:"color"` + Title string `gorm:"type:varchar(254)" json:"title,omitempty" form:"title"` + Text1 string `gorm:"type:varchar(254)" json:"text1,omitempty" form:"text1"` + Text2 string `gorm:"type:varchar(254)" json:"text2,omitempty" form:"text2"` + Text4 string `gorm:"type:varchar(254)" json:"text4,omitempty" form:"text4"` + Text5 string `gorm:"type:varchar(254)" json:"text5,omitempty" form:"text5"` + Image string `gorm:"type:varchar(254)" json:"image" form:"image"` + IsActive bool `gorm:"default:true" json:"is_active" form:"is_active"` + Width int `gorm:"default:0" json:"width" form:"width"` + Height int `gorm:"default:0" json:"height" form:"height"` + Quality int `gorm:"default:0" json:"quality" form:"quality"` + Format string `gorm:"type:varchar(10)" json:"format" form:"format"` +} diff --git a/app/database/models/setting.go b/app/database/models/setting.go new file mode 100644 index 0000000..cfcd73e --- /dev/null +++ b/app/database/models/setting.go @@ -0,0 +1,43 @@ +package models + +import ( + "gorm.io/gorm" +) + +// Setting model structure +// Stores site-wide metadata and contact information. +type Setting struct { + gorm.Model + Title string `gorm:"type:varchar(254);not null" json:"title" form:"title"` + MetaTitle string `gorm:"type:varchar(254);not null" json:"meta_title" form:"meta_title"` + MetaDescription string `gorm:"type:varchar(254);not null" json:"meta_description" form:"meta_description"` + Phone string `gorm:"type:varchar(254);not null" json:"phone" form:"phone"` + URL string `gorm:"type:varchar(254);not null" json:"url" form:"url"` + Email string `gorm:"type:varchar(254);not null" json:"email" form:"email"` + Facebook string `gorm:"type:varchar(254)" json:"facebook,omitempty" form:"facebook"` + X string `gorm:"type:varchar(254)" json:"x,omitempty" form:"x"` + Instagram string `gorm:"type:varchar(254)" json:"instagram,omitempty" form:"instagram"` + Whatsapp string `gorm:"type:varchar(254)" json:"whatsapp,omitempty" form:"whatsapp"` + Pinterest string `gorm:"type:varchar(254)" json:"pinterest,omitempty" form:"pinterest"` + Linkedin string `gorm:"type:varchar(254)" json:"linkedin,omitempty" form:"linkedin"` + Slogan string `gorm:"type:varchar(254)" json:"slogan,omitempty" form:"slogan"` + Address string `gorm:"type:text" json:"address,omitempty" form:"address"` + Copyright string `gorm:"type:varchar(254)" json:"copyright,omitempty" form:"copyright"` + MapEmbed string `gorm:"type:text" json:"map_embed,omitempty" form:"map_embed"` + WLogo string `gorm:"type:text" json:"w_logo,omitempty" form:"w_logo"` + BLogo string `gorm:"type:text" json:"b_logo,omitempty" form:"b_logo"` + IsActive bool `gorm:"default:false" json:"is_active" form:"is_active"` + WWidth int `gorm:"default:0" json:"w_width" form:"w_width"` + WHeight int `gorm:"default:0" json:"w_height" form:"w_height"` + WQuality int `gorm:"default:0" json:"w_quality" form:"w_quality"` + WFormat string `gorm:"type:varchar(10)" json:"w_format" form:"w_format"` + BWidth int `gorm:"default:0" json:"b_width" form:"b_width"` + BHeight int `gorm:"default:0" json:"b_height" form:"b_height"` + BQuality int `gorm:"default:0" json:"b_quality" form:"b_quality"` + BFormat string `gorm:"type:varchar(10)" json:"b_format" form:"b_format"` +} + +// TableName overrides the table name used by Setting to `settings` +func (Setting) TableName() string { + return "settings" +} diff --git a/app/database/models/user.go b/app/database/models/user.go new file mode 100644 index 0000000..bb69e38 --- /dev/null +++ b/app/database/models/user.go @@ -0,0 +1,51 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` + UserName string `json:"username" gorm:"type:varchar(255)"` + Email string `gorm:"uniqueIndex;not null;type:varchar(255)" json:"email"` + Password string `json:"-" gorm:"type:varchar(255)"` // Password shouldn't be returned in JSON + EmailVerified *bool `gorm:"default:false" json:"email_verified"` // default false for email/password registration + EmailVerifyToken string `gorm:"index;type:varchar(255)" json:"-"` + EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty"` + IsAdmin *bool `gorm:"default:false" json:"is_admin"` + SocialAccounts []SocialAccount `gorm:"foreignKey:UserID" json:"social_accounts,omitempty"` + Profile []Profile `gorm:"foreignKey:UserID" json:"profiles,omitempty"` +} + +// Email Veriyf i False Döndürüyor +func (u *User) IsEmailVerified() bool { + if u.EmailVerified == nil { + return false + } + return *u.EmailVerified +} + +// SocialAccount model structure +type SocialAccount struct { + gorm.Model + UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"` + Provider string `gorm:"not null" json:"provider"` // google, github + ProviderID string `gorm:"not null" json:"provider_id"` + Email string `json:"email" gorm:"type:varchar(255)"` + Name string `json:"name,omitempty" gorm:"type:varchar(255)"` // Full name from provider + AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider + +} +type Profile struct { + gorm.Model + UserID uint64 `gorm:"type:bigint unsigned;not null;index" json:"user_id"` + AvatarURL string `json:"avatar_url,omitempty" gorm:"type:varchar(255)"` // Avatar URL from provider + FirstName string `json:"first_name" gorm:"type:varchar(255)"` // Full name from provider + LastName string `json:"last_name" gorm:"type:varchar(255)"` // Full name from provider + +} diff --git a/app/database/seed/seed.go b/app/database/seed/seed.go new file mode 100644 index 0000000..28c5c73 --- /dev/null +++ b/app/database/seed/seed.go @@ -0,0 +1,119 @@ +package seed + +import ( + "goGin/app/database/models" + "log" + + dbconfig "goGin/app/database/config" + + "golang.org/x/crypto/bcrypt" +) + +func SeedDefaultSettings() { + // Seed default CORS whitelist + var whitelistCount int64 + dbconfig.DB.Model(&models.CorsWhitelist{}).Count(&whitelistCount) + + if whitelistCount == 0 { + defaultWhitelist := []models.CorsWhitelist{ + { + Origin: "http://localhost:3000", + Description: "Default local frontend", + IsActive: true, + CreatedBy: "system", + }, + { + Origin: "http://localhost:8080", + Description: "Backend self", + IsActive: true, + CreatedBy: "system", + }, + } + + for _, w := range defaultWhitelist { + dbconfig.DB.Create(&w) + } + log.Println("Default CORS whitelist seeded") + } + + // Seed default rate limit settings + var rateLimitCount int64 + dbconfig.DB.Model(&models.RateLimitSetting{}).Count(&rateLimitCount) + + if rateLimitCount == 0 { + defaultRateLimits := []models.RateLimitSetting{ + { + Name: "login", + Description: "Login endpoint rate limit", + MaxRequests: 5, + WindowSeconds: 60, // 1 minute + IsActive: true, + }, + { + Name: "register", + Description: "Registration endpoint rate limit", + MaxRequests: 3, + WindowSeconds: 300, // 5 minutes + IsActive: true, + }, + { + Name: "api", + Description: "General API rate limit", + MaxRequests: 100, + WindowSeconds: 60, // 1 minute + IsActive: true, + }, + } + + for _, r := range defaultRateLimits { + dbconfig.DB.Create(&r) + } + log.Println("Default rate limit settings seeded") + } +} + +// SeedDefaultAdmin creates the default admin user if it doesn't exist +func SeedDefaultAdmin() { + // Check if admin user already exists (including soft-deleted) + var adminUser models.User + err := dbconfig.DB.Unscoped().Where("email = ?", "admin@gauth.local").First(&adminUser).Error + + if err != nil { + // Admin user doesn't exist, create one + // Hash default password: "Admin@123" + hashedPassword, err := bcrypt.GenerateFromPassword([]byte("Admin@123"), bcrypt.DefaultCost) + if err != nil { + log.Printf("Failed to hash admin password: %v", err) + return + } + + trueBool := true + adminUser = models.User{ + Email: "admin@gauth.local", + UserName: "admin", + Password: string(hashedPassword), + EmailVerified: &trueBool, + } + + if err := dbconfig.DB.Create(&adminUser).Error; err != nil { + log.Printf("Failed to create admin user: %v", err) + return + } + + log.Println("✅ Default admin user created:") + log.Println(" Email: admin@gauth.local") + log.Println(" Password: Admin@123") + log.Println(" ⚠️ Please change this password after first login!") + } else { + // Admin user exists (possibly soft-deleted) + if adminUser.DeletedAt.Valid { + log.Println("Restoring deleted admin user...") + if err := dbconfig.DB.Model(&adminUser).Unscoped().Update("deleted_at", nil).Error; err != nil { + log.Printf("Failed to restore admin user: %v", err) + return + } + } + } + + // Admin rolü eklenmesi kaldırıldı çünkü Role modeli yok +} diff --git a/app/middlewares/auth_middleware.go b/app/middlewares/auth_middleware.go new file mode 100644 index 0000000..73b22bd --- /dev/null +++ b/app/middlewares/auth_middleware.go @@ -0,0 +1,91 @@ +package middlewares + +import ( + "net/http" + "strings" + + configs "goGin/config" + "goGin/app/services" + + "github.com/gin-gonic/gin" + "log" +) + +const authClaimsKey = "auth_claims" + +func RequireAuth(c *gin.Context) { + authHeader := strings.TrimSpace(c.GetHeader("Authorization")) + if authHeader == "" { + authLogf("auth: missing Authorization header path=%s", c.Request.URL.Path) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authorization header is required"}) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || strings.TrimSpace(parts[1]) == "" { + authLogf("auth: invalid authorization format path=%s header=%s", c.Request.URL.Path, authHeader) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization format, expected: Bearer "}) + return + } + + jwtService := services.NewJWTService() + claims, err := jwtService.ValidateToken(strings.TrimSpace(parts[1])) + if err != nil { + authLogf("auth: invalid token path=%s error=%v", c.Request.URL.Path, err) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + if claims.TokenType != services.TokenTypeAccess { + authLogf("auth: non-access token used path=%s token_type=%s", c.Request.URL.Path, claims.TokenType) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "access token required"}) + return + } + + c.Set(authClaimsKey, claims) + c.Next() +} + +func RequireAdmin(c *gin.Context) { + claims, ok := GetAuthClaims(c) + if !ok { + authLogf("auth: RequireAdmin missing claims path=%s", c.Request.URL.Path) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if !claims.IsAdmin { + authLogf("auth: RequireAdmin forbidden path=%s user_id=%v", c.Request.URL.Path, claims.UserID) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin role required"}) + return + } + c.Next() +} + +func RequireNormalUser(c *gin.Context) { + claims, ok := GetAuthClaims(c) + if !ok { + authLogf("auth: RequireNormalUser missing claims path=%s", c.Request.URL.Path) + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + if claims.IsAdmin { + authLogf("auth: RequireNormalUser forbidden (admin tried to access) path=%s user_id=%v", c.Request.URL.Path, claims.UserID) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "only normal users can access this endpoint"}) + return + } + c.Next() +} + +func GetAuthClaims(c *gin.Context) (*services.JWTClaim, bool) { + raw, exists := c.Get(authClaimsKey) + if !exists { + return nil, false + } + claims, ok := raw.(*services.JWTClaim) + return claims, ok +} + +func authLogf(format string, args ...interface{}) { + if configs.AppConfig != nil && configs.AppConfig.Debug { + log.Printf(format, args...) + } +} diff --git a/app/middlewares/dynamic_cors.go b/app/middlewares/dynamic_cors.go new file mode 100644 index 0000000..8bedf51 --- /dev/null +++ b/app/middlewares/dynamic_cors.go @@ -0,0 +1,154 @@ +package middlewares + +import ( + "encoding/json" + "errors" + configs "goGin/config" + database "goGin/app/database/config" + "goGin/app/database/models" + "log" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" +) + +const ( + corsWhitelistActiveCacheKey = "cors:active:whitelist" + corsBlacklistActiveCacheKey = "cors:active:blacklist" + corsCacheTTLSeconds = 60 +) + +var ( + allowedMethods = "GET,POST,PUT,PATCH,DELETE,OPTIONS" + allowedHeaders = "Authorization,Content-Type,Accept,Origin,X-Requested-With" +) + +// DynamicCORS validates request Origin using DB-backed whitelist/blacklist with Redis caching. +func DynamicCORS() gin.HandlerFunc { + return func(c *gin.Context) { + origin := strings.TrimSpace(c.GetHeader("Origin")) + if origin == "" { + c.Next() + return + } + if database.DB == nil { + corsLogf("[cors][skip] database unavailable origin=%s path=%s", origin, c.Request.URL.Path) + c.Next() + return + } + + originKey := strings.ToLower(origin) + // Keep same-origin requests working even if DB entries are missing. + if origin == requestBaseURL(c) { + corsLogf("[cors][allow] same-origin origin=%s path=%s", origin, c.Request.URL.Path) + setCORSHeaders(c, origin) + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + return + } + + blacklist, err := loadActiveOriginSet(corsBlacklistActiveCacheKey, true) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "cors blacklist lookup failed"}) + return + } + if blacklist[originKey] { + log.Printf("[cors][blocked] blacklist origin=%s path=%s", origin, c.Request.URL.Path) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "origin is blocked by CORS policy"}) + return + } + + whitelist, err := loadActiveOriginSet(corsWhitelistActiveCacheKey, false) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "cors whitelist lookup failed"}) + return + } + if !whitelist[originKey] { + log.Printf("[cors][blocked] not-whitelisted origin=%s path=%s", origin, c.Request.URL.Path) + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "origin is not allowed by CORS policy"}) + return + } + + corsLogf("[cors][allow] origin=%s path=%s", origin, c.Request.URL.Path) + setCORSHeaders(c, origin) + if c.Request.Method == http.MethodOptions { + c.AbortWithStatus(http.StatusNoContent) + return + } + c.Next() + } +} + +func setCORSHeaders(c *gin.Context, origin string) { + c.Header("Vary", "Origin") + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Methods", allowedMethods) + c.Header("Access-Control-Allow-Headers", allowedHeaders) + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Max-Age", "600") +} + +func requestBaseURL(c *gin.Context) string { + scheme := c.Request.Header.Get("X-Forwarded-Proto") + if scheme == "" { + if c.Request.TLS != nil { + scheme = "https" + } else { + scheme = "http" + } + } + return scheme + "://" + c.Request.Host +} + +func loadActiveOriginSet(cacheKey string, isBlacklist bool) (map[string]bool, error) { + out := make(map[string]bool) + + if cached, err := database.Get(cacheKey); err == nil { + corsLogf("[cors][cache-hit] key=%s", cacheKey) + var origins []string + if jsonErr := json.Unmarshal([]byte(cached), &origins); jsonErr == nil { + for _, origin := range origins { + out[strings.ToLower(strings.TrimSpace(origin))] = true + } + return out, nil + } + } else if !errors.Is(err, redis.Nil) { + return nil, err + } + corsLogf("[cors][cache-miss] key=%s", cacheKey) + + var origins []string + var dbErr error + if isBlacklist { + dbErr = database.DB.Model(&models.CorsBlacklist{}). + Where("is_active = ?", true). + Pluck("origin", &origins).Error + } else { + dbErr = database.DB.Model(&models.CorsWhitelist{}). + Where("is_active = ?", true). + Pluck("origin", &origins).Error + } + if dbErr != nil { + return nil, dbErr + } + + for _, origin := range origins { + out[strings.ToLower(strings.TrimSpace(origin))] = true + } + + cacheBytes, _ := json.Marshal(origins) + _ = database.SetEx(cacheKey, string(cacheBytes), corsCacheTTLSeconds) + + return out, nil +} + +func corsLogf(format string, args ...interface{}) { + if configs.AppConfig != nil && (configs.AppConfig.Debug || configs.AppConfig.CorsDebug) { + log.Printf(format, args...) + } +} diff --git a/app/middlewares/rate_limit.go b/app/middlewares/rate_limit.go new file mode 100644 index 0000000..e8d94bf --- /dev/null +++ b/app/middlewares/rate_limit.go @@ -0,0 +1,128 @@ +package middlewares + +import ( + "context" + "encoding/json" + "errors" + "fmt" + configs "goGin/config" + database "goGin/app/database/config" + "goGin/app/database/models" + "log" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +type rateLimitRuntime struct { + Name string `json:"name"` + MaxRequests int64 `json:"max_requests"` + WindowSeconds int `json:"window_seconds"` + IsActive bool `json:"is_active"` +} + +// RequireRateLimit applies Redis-backed per-IP rate limiting by setting name. +func RequireRateLimit(name string, fallbackMax int64, fallbackWindowSeconds int) gin.HandlerFunc { + return func(c *gin.Context) { + if database.DB == nil { + c.Next() + return + } + + setting, err := loadRateLimitRuntime(name, fallbackMax, fallbackWindowSeconds) + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "rate limit configuration error"}) + return + } + if !setting.IsActive { + c.Next() + return + } + if database.RedisClient == nil { + rateLimitLogf("[rate-limit][warn] redis unavailable, skipping enforcement name=%s", setting.Name) + c.Next() + return + } + + ip := strings.TrimSpace(c.ClientIP()) + if ip == "" { + ip = "unknown" + } + + counterKey := fmt.Sprintf("ratelimit:%s:%s", setting.Name, ip) + count, err := database.RedisClient.Incr(context.Background(), counterKey).Result() + if err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "rate limit check failed"}) + return + } + if count == 1 { + _ = database.RedisClient.Expire(context.Background(), counterKey, time.Duration(setting.WindowSeconds)*time.Second).Err() + } + + if count > setting.MaxRequests { + ttl, _ := database.RedisClient.TTL(context.Background(), counterKey).Result() + retryAfter := int(ttl.Seconds()) + if retryAfter < 1 { + retryAfter = setting.WindowSeconds + } + c.Header("Retry-After", strconv.Itoa(retryAfter)) + log.Printf("[rate-limit][blocked] name=%s ip=%s count=%d max=%d window=%ds", setting.Name, ip, count, setting.MaxRequests, setting.WindowSeconds) + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "too many requests", + "retry_after": retryAfter, + }) + return + } + + rateLimitLogf("[rate-limit][allow] name=%s ip=%s count=%d max=%d window=%ds", setting.Name, ip, count, setting.MaxRequests, setting.WindowSeconds) + c.Next() + } +} + +func loadRateLimitRuntime(name string, fallbackMax int64, fallbackWindowSeconds int) (*rateLimitRuntime, error) { + cacheKey := "ratelimit:setting:" + name + if cached, err := database.Get(cacheKey); err == nil { + var s rateLimitRuntime + if jsonErr := json.Unmarshal([]byte(cached), &s); jsonErr == nil { + return &s, nil + } + } else if !errors.Is(err, redis.Nil) { + return nil, err + } + + setting := &rateLimitRuntime{ + Name: name, + MaxRequests: fallbackMax, + WindowSeconds: fallbackWindowSeconds, + IsActive: true, + } + + var dbSetting models.RateLimitSetting + if err := database.DB.Where("name = ?", name).First(&dbSetting).Error; err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + rateLimitLogf("[rate-limit][config] setting=%s not found, using fallback max=%d window=%ds", name, fallbackMax, fallbackWindowSeconds) + } else { + setting.MaxRequests = dbSetting.MaxRequests + setting.WindowSeconds = dbSetting.WindowSeconds + setting.IsActive = dbSetting.IsActive + rateLimitLogf("[rate-limit][config] loaded from db name=%s active=%t max=%d window=%ds", name, setting.IsActive, setting.MaxRequests, setting.WindowSeconds) + } + + cacheJSON, _ := json.Marshal(setting) + _ = database.SetEx(cacheKey, string(cacheJSON), 60) + + return setting, nil +} + +func rateLimitLogf(format string, args ...interface{}) { + if configs.AppConfig != nil && (configs.AppConfig.Debug || configs.AppConfig.CorsDebug) { + log.Printf(format, args...) + } +} diff --git a/app/routes/router.go b/app/routes/router.go new file mode 100644 index 0000000..8dcbdb1 --- /dev/null +++ b/app/routes/router.go @@ -0,0 +1,150 @@ +package routes + +import ( + "log" + + "goGin/app/controllers" + "goGin/app/middlewares" + + "github.com/gin-gonic/gin" +) + +// Böylece Gin artık “tüm proxy’lere güveniyorum” modundan çıktı, uyarı gidecek ve IP/scheme güvenliği artmış olacak. +// İleride reverse proxy arkası deploy yaparsan, SetTrustedProxies(nil) +// satırını kendi proxy IP / CIDR’larınla değiştirebiliriz. +func SetupRouter() *gin.Engine { + r := gin.Default() + // Güvenli varsayılan: hiçbir proxy'ye güvenme (lokal geliştirme ve basit deploy'lar için ideal). + // İleride reverse proxy arkasına alırsan, ilgili IP/CIDR bloklarını burada SetTrustedProxies ile tanımlayabilirsin. + if err := r.SetTrustedProxies(nil); err != nil { + log.Fatalf("failed to set trusted proxies: %v", err) + } + r.Use(middlewares.DynamicCORS()) + r.Use(middlewares.RequireRateLimit("global", 100, 60)) + // Uploads klasörünü statik olarak dışarı açıyoruz + r.Static("/uploads", "./uploads") + + api := r.Group("/api/v1") + admin := r.Group("/api/v1/admin") + // Protect admin group with auth + admin requirement + admin.Use(middlewares.RequireAuth) + admin.Use(middlewares.RequireAdmin) + + auth := r.Group("/api/v1/auth") + { + auth.POST("/register", controllers.Register, middlewares.RequireRateLimit("register", 10, 60)) + auth.POST("/login", controllers.Login, middlewares.RequireRateLimit("login", 10, 60)) + auth.POST("/refresh", controllers.Refresh) + auth.GET("/verify-email", controllers.VerifyEmail, middlewares.RequireRateLimit("verify_email", 10, 60)) + + // Protected auth endpoints + authProtected := auth.Group("") + authProtected.Use(middlewares.RequireAuth) + { + authProtected.GET("/me", controllers.Me) + } + + auth.GET("/google", controllers.GoogleLogin) + auth.GET("/google/callback", controllers.GoogleCallback) + auth.GET("/github", controllers.GithubLogin) + auth.GET("/github/callback", controllers.GithubCallback) + } + + // Public GET endpoints + api.GET("/posts", controllers.ListPosts) + // use slug instead of numeric id + api.GET("/posts/:slug", controllers.GetPost) + api.GET("/categories", controllers.ListCategories) + // use slug for category retrieval as well + api.GET("/categories/:slug", controllers.GetCategory) + api.GET("/tags", controllers.ListTags) + api.GET("/tags/:id", controllers.GetTag) + api.GET("/comments", controllers.ListComments) + api.GET("/comments/:id", controllers.GetComment) + api.GET("/categoryviews", controllers.ListCategoryViews) + api.GET("/categoryviews/:id", controllers.GetCategoryView) + api.GET("/tags/:id/posts", controllers.FilterPostsByTag) + + // Settings public endpoints + api.GET("/settings", controllers.GetSettings) + // optional public get by id + api.GET("/settings/:id", controllers.AdminGetSetting) + + // Hero public endpoints + api.GET("/heroes", controllers.ListHeroes) + api.GET("/heroes/:id", controllers.GetHero) + + // User routes (Profile) + userGroup := api.Group("/users") + userGroup.Use(middlewares.RequireAuth) + { + userGroup.GET("/profile", controllers.GetProfile) + userGroup.PUT("/profile", controllers.UpdateProfile) + } + + // Admin POST, PUT, DELETE endpoints + admin.POST("/posts", controllers.CreatePost) + admin.GET("/posts/:id", controllers.AdminGetPost) + admin.PUT("/posts/:id", controllers.UpdatePost) + admin.DELETE("/posts/:id", controllers.DeletePost) + // Admin GET list (with soft-delete filters) + admin.GET("/posts", controllers.AdminListPosts) + // soft-delete management + admin.GET("/posts/deleted", controllers.ListDeletedPosts) + admin.POST("/posts/:id/restore", controllers.RestorePost) + + admin.POST("/categories", controllers.CreateCategory) + admin.PUT("/categories/:id", controllers.UpdateCategory) + admin.DELETE("/categories/:id", controllers.DeleteCategory) + // Admin GET list (with soft-delete filters) + admin.GET("/categories", controllers.AdminListCategories) + // categories soft-delete management + admin.GET("/categories/deleted", controllers.ListDeletedCategories) + admin.POST("/categories/:id/restore", controllers.RestoreCategory) + + admin.POST("/tags", controllers.CreateTag) + admin.PUT("/tags/:id", controllers.UpdateTag) + admin.DELETE("/tags/:id", controllers.DeleteTag) + // Admin GET list (with soft-delete filters) + admin.GET("/tags", controllers.AdminListTags) + admin.POST("/tags/:id/restore", controllers.RestoreTag) + + admin.POST("/comments", controllers.CreateComment) + admin.PUT("/comments/:id", controllers.UpdateComment) + admin.DELETE("/comments/:id", controllers.DeleteComment) + // Admin GET list (with soft-delete filters) + admin.GET("/comments", controllers.AdminListComments) + + admin.POST("/categoryviews", controllers.CreateCategoryView) + // Admin GET list (with soft-delete filters) + admin.GET("/categoryviews", controllers.AdminListCategoryViews) + + // Admin Settings endpoints + admin.GET("/settings", controllers.AdminListSettings) + admin.POST("/settings", controllers.AdminCreateSetting) + admin.GET("/settings/:id", controllers.AdminGetSetting) + admin.PUT("/settings/:id", controllers.AdminUpdateSetting) + admin.DELETE("/settings/:id", controllers.AdminDeleteSetting) + admin.POST("/settings/:id/restore", controllers.AdminRestoreSetting) + + // Hero Admin endpoints + admin.GET("/heroes", controllers.AdminListHeroes) + admin.POST("/heroes", controllers.CreateHero) + admin.GET("/heroes/:id", controllers.AdminGetHero) + admin.PUT("/heroes/:id", controllers.UpdateHero) + admin.DELETE("/heroes/:id", controllers.DeleteHero) + admin.POST("/heroes/:id/restore", controllers.RestoreHero) + + // User Management + admin.GET("/users", controllers.AdminListUsers) + admin.GET("/users/:id", controllers.AdminGetUser) + admin.PUT("/users/:id", controllers.AdminUpdateUser) + admin.DELETE("/users/:id", controllers.AdminDeleteUser) + admin.POST("/users/:id/restore", controllers.AdminRestoreUser) + + // İlişkili işlemler + admin.POST("/posts/:id/comments", controllers.AddCommentToPost) + admin.POST("/categories/:id/posts", controllers.AddPostToCategory) + + return r +} diff --git a/app/services/jwt_service.go b/app/services/jwt_service.go new file mode 100644 index 0000000..9db0a2d --- /dev/null +++ b/app/services/jwt_service.go @@ -0,0 +1,127 @@ +package services + +import ( + "errors" + "fmt" + configs "goGin/config" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// TokenTypeAccess sabiti, auth middleware'in beklediği token türünü temsil eder. +const TokenTypeAccess = "access" + +// JWTClaim, authorization middleware'inin beklediği claim yapısını temsil eder. +// İleride ihtiyaç oldukça alanlar genişletilebilir. +type JWTClaim struct { + TokenType string + IsAdmin bool + UserID any +} + +// JWTService, JWT ile ilgili operasyonları kapsayan servis. +type JWTService struct { + secret []byte +} + +// NewJWTService yeni bir JWTService örneği döner. +// Secret, config içindeki JWT_SECRET üzerinden okunur. +func NewJWTService() *JWTService { + secret := "" + if configs.AppConfig != nil { + secret = configs.AppConfig.JWTSecret + } + return &JWTService{ + secret: []byte(secret), + } +} + +// ValidateToken verilen JWT'yi doğrular ve claim'leri döner. +// HMAC (HS256 vb.) ile imzalanmış token'lar beklenir. +func (s *JWTService) ValidateToken(tokenString string) (*JWTClaim, error) { + if len(s.secret) == 0 { + return nil, errors.New("jwt secret is not configured") + } + + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) { + // Sadece HMAC algoritmalarına izin veriyoruz. + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + return s.secret, nil + }) + if err != nil { + return nil, err + } + if !token.Valid { + return nil, errors.New("invalid token") + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, errors.New("invalid token claims") + } + + // Token tipi (access / refresh vs.) + var tokenType string + if v, ok := claims["token_type"].(string); ok { + tokenType = v + } else if v, ok := claims["tokenType"].(string); ok { + tokenType = v + } + + // Yönetici flag'i + var isAdmin bool + if v, ok := claims["is_admin"]; ok { + switch vv := v.(type) { + case bool: + isAdmin = vv + case float64: + isAdmin = vv != 0 + } + } else if v, ok := claims["isAdmin"]; ok { + if vv, ok2 := v.(bool); ok2 { + isAdmin = vv + } + } + + // Kullanıcı ID'si (sub claim'i üzerinden) + var userID any + if v, ok := claims["sub"]; ok { + userID = v + } else if v, ok := claims["user_id"]; ok { + userID = v + } + + return &JWTClaim{ + TokenType: tokenType, + IsAdmin: isAdmin, + UserID: userID, + }, nil +} + +// GenerateToken creates a short-lived access token +func (s *JWTService) GenerateToken(userID uint, isAdmin bool) (string, error) { + claims := jwt.MapClaims{ + "sub": userID, + "is_admin": isAdmin, + "token_type": TokenTypeAccess, + "exp": jwt.NewNumericDate(time.Now().Add(time.Duration(configs.AppConfig.AccessTokenExpireMinutes) * time.Minute)), + "iat": jwt.NewNumericDate(time.Now()), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(s.secret) +} + +// GenerateRefreshToken creates a long-lived refresh token +func (s *JWTService) GenerateRefreshToken(userID uint) (string, error) { + claims := jwt.MapClaims{ + "sub": userID, + "token_type": "refresh", + "exp": jwt.NewNumericDate(time.Now().Add(time.Duration(configs.AppConfig.RefreshTokenExpireDays) * 24 * time.Hour)), + "iat": jwt.NewNumericDate(time.Now()), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(s.secret) +} diff --git a/belgeler/admin_panel.md b/belgeler/admin_panel.md new file mode 100644 index 0000000..3d946b7 --- /dev/null +++ b/belgeler/admin_panel.md @@ -0,0 +1,99 @@ +Bağlam: +- Proje Next.js 16, React 19, TypeScript kullanıyor. +- Mevcut bağımlılıklar (önemli olanlar): next@16.1.6, react@19.2.3, next-auth, tailwindcss@^4, shadcn, radix-ui, lucide-react, class-variance-authority, clsx, tailwind-merge, zod, sweetalert2, nextjs-turnstile, tw-animate-css. +- Yeni paket eklemeden önce önce onay iste. + +Görev: +Tüm site temel olarak public olacak; ana içerik sayfaları (ana sayfa, blog/ürün listesi, ürün detay, hakkında, iletişim vb.) herkese açık. Bunun yanında /admin yolu altında ayrı, yetki kontrollü bir Admin Paneli olacak — bu alana sadece next-auth ile oturum açmış ve rolü "admin" olan kullanıcılar erişebilecek. Frontend Next.js 16 + TypeScript ile, mevcut bağımlılıkları kullanarak oluşturulacak; kod temiz, modüler ve production-ready olacak. + +Mimari/Genel kurallar: +- Next.js 16, app router (tercih) ve TypeScript (strict). +- Styling: TailwindCSS v4 + shadcn komponentleri + Radix UI primitives. İkonlar: lucide-react. +- Form schema/validasyon: Zod (hem client hem server). +- Auth: next-auth ile role tabanlı erişim (user | admin). Login formunda Turnstile kullanılacak. +- UI yardımcıları: class-variance-authority, clsx, tailwind-merge. +- Uyarılar/confirm: SweetAlert2. +- Yeni bağımlılık eklenmeden önce onay iste. +- Veri katmanı: başlangıç mock/JSON veya isteğe göre Prisma+Postgres öner (ek paket için onay al). + +Kapsam / Özellikler: +1. Public frontend +- Ana sayfa, katalog (liste), öğe detayı, hakkında, iletişim gibi public rotalar (SSR/SSG uygun yerlere göre). +- SEO meta, Open Graph etiketleri, erişilebilir markup. +- Responsive ve mobil-first tasarım. +2. Authentication & Authorization +- next-auth ile oturum yönetimi; kullanıcı nesnesinde role alanı. +- /auth/signin sayfası (Turnstile entegrasyonu). +- Admin alanına erişim: server-side guard (middleware veya server component) ve client-side koruma. Admin değilse /admin altında 403 veya redirect to /auth/signin. +3. Admin Panel (/admin) +- Layout: sidebar, header, responsive. +- Dashboard: KPI kartları (mock veya API), temel yönetim araçları. +- User Management: liste/arama/pagination, rol değiştir, aktif/pasif. +- Örnek CRUD: Products (create/read/update/delete) için tam çalışan UI ve API (Zod validasyonlu). +- Settings: admin için site ayarları (mock). +4. API & Veri +- Next.js route handlers (app router) veya /api altında tipli endpoints: /api/admin/users, /api/admin/products vb. +- Başlangıç için mock veri veya JSON file; gerçek DB istenirse Prisma + Postgres öner (migration ve .env örneği ile). +5. Formlar & Validasyon +- Zod şemaları hem client hem server tarafında kullanılsın. +- Hatalar kullanıcıya anlamlı gösterilsin. +6. UI & A11y +- shadcn komponentleri ve Radix primitives ile erişilebilir bileşenler. +- Lucide ikonları, SweetAlert2 confirm/toast. +- Keyboard erişimi ve aria-* attribute’ları. +7. Güvenlik +- Admin rotaları server-side yetki kontrolü. +- CSRF koruması next-auth ile sağlanmalı. +- Turnstile login/critical formlar için. +8. Tests (tercihli) +- Örnek birim testi ve en az 1 API entegrasyon testi (Jest veya testing-library; ek paket gerekiyorsa onay iste). +9. Kod kalitesi & teslimat +- TypeScript strict, ESLint ile uyumlu. +- Commit mesajları: feat/admin-*, fix/*, chore/*. +- PR: branch feat/admin-panel veya isteğe göre parça parça PR tercih edilebilir. + +Klasör yapısı önerisi: +- app/ + - layout.tsx (global) + - page.tsx (public ana sayfa) + - products/ + - page.tsx + - [slug]/page.tsx + - (admin)/ + - layout.tsx + - page.tsx (dashboard) + - users/ + - page.tsx + - [id]/page.tsx + - products/ + - ... +- components/ + - ui/ (shadcn/radix wrappers) + - public/ (Hero, Footer, Header) + - admin/ (Sidebar, AdminTable, ProductForm) +- lib/ + - auth.ts (next-auth config) + - db.ts (data access abstraction) + - schemas/ (zod schemas) +- pages/api/ or app/api/ + - admin/ + - users.ts + - products.ts +- styles/ (tailwind globals) + +Acceptance kriterleri: +- Public sayfalar (ör. /, /products, /products/[slug]) herkes tarafından erişilebilir ve düzgün render oluyor. +- /admin sadece role === "admin" kullanıcılar tarafından erişilebilir; diğerleri redirect veya 403 alıyor. +- Login sayfası Turnstile çalışıyor. +- Product CRUD çalışıyor (Zod validasyon server+client). +- TypeScript derlemesi hatasız. +- README içinde gerekli env değişkenleri (NEXTAUTH_*, DATABASE_URL, TURNSTILE keys) ve çalıştırma talimatları var. +- PR açıklaması: hangi feature’lar eklendi, nasıl test edilir, eksikler/öneriler. + +Teslim formatı: +- Tercih: tek PR (feat/admin-panel) veya küçük aşama PR’ları (her biri açıklamalı). README ve .env.example ekle. +- PR açıklamasında çalışma, test etme ve env değişkenleri belirtilmiş olacak. + +Zaman tahmini (yaklaşık): +- Minimal (mock veri, temel public sayfalar + admin auth + temel CRUD): 3-5 iş günü. +- Tam (DB, testler, audit log, eksiksiz validasyon): 7-14 iş günü. \ No newline at end of file diff --git a/cmd/seeder/main.go b/cmd/seeder/main.go new file mode 100644 index 0000000..4980b05 --- /dev/null +++ b/cmd/seeder/main.go @@ -0,0 +1,250 @@ +package main + +import ( + "fmt" + "io" + "log" + "math/rand" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + database "goGin/app/database/config" + "goGin/app/database/models" + configs "goGin/config" + + "gorm.io/gorm" +) + +func main() { + // Config ve DB yükle + configs.LoadConfig() + database.ConnectDB() + + db := database.DB + if db == nil { + log.Fatal("Veritabanı bağlantısı kurulamadı!") + } + + log.Println("Seeder başladı...") + + // create a local random generator to avoid deprecated rand.Seed usage + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + // 1. Kullanıcıyı Kontrol Et / Oluştur (ID: 2) + var user models.User + result := db.First(&user, 2) + if result.Error != nil { + if result.Error == gorm.ErrRecordNotFound { + log.Println("Kullanıcı (ID: 2) bulunamadı, oluşturuluyor...") + user = models.User{ + ID: 2, + UserName: "fakeuser", + Email: "fakeuser@example.com", + Password: "password123", // Hashlenmemiş, sadece test için + } + isAdmin := false + emailVerified := true + user.IsAdmin = &isAdmin + user.EmailVerified = &emailVerified + + if err := db.Create(&user).Error; err != nil { + log.Printf("Kullanıcı oluşturulurken hata: %v", err) + user.ID = 0 + if err2 := db.Create(&user).Error; err2 != nil { + log.Fatal("Kullanıcı oluşturulamadı:", err2) + } + } + } else { + log.Fatal("Veritabanı hatası:", result.Error) + } + } else { + log.Println("Kullanıcı (ID: 2) mevcut.") + } + + // 2. Kategorileri Oluştur + categories := []struct { + Title string + Subs []string + }{ + {"Teknoloji", []string{"Yazılım", "Donanım", "Yapay Zeka", "Mobil"}}, + {"Yaşam", []string{"Sağlık", "Spor", "Gezi", "Dekorasyon"}}, + {"Yemek", []string{"Tatlılar", "Ana Yemekler", "İçecekler"}}, + {"Sanat", []string{"Sinema", "Müzik", "Edebiyat"}}, + } + + var createdCategories []models.Category + + for _, catData := range categories { + parent := models.Category{ + Title: catData.Title, + Slug: slugify(catData.Title), + } + if err := db.Where("slug = ?", parent.Slug).FirstOrCreate(&parent).Error; err != nil { + log.Printf("Kategori hatası (%s): %v", parent.Title, err) + continue + } + createdCategories = append(createdCategories, parent) + + for _, subTitle := range catData.Subs { + sub := models.Category{ + Title: subTitle, + Slug: slugify(subTitle), + ParentID: &parent.ID, + } + if err := db.Where("slug = ?", sub.Slug).FirstOrCreate(&sub).Error; err != nil { + log.Printf("Alt kategori hatası (%s): %v", subTitle, err) + continue + } + createdCategories = append(createdCategories, sub) + } + } + + // 3. Etiketleri Oluştur + tagNames := []string{"Go", "Gin", "Web", "Api", "Tutorial", "Coding", "Life", "Nature", "Food", "Travel"} + var createdTags []models.Tag + for _, name := range tagNames { + tag := models.Tag{Name: name} + if err := db.Where("name = ?", name).FirstOrCreate(&tag).Error; err != nil { + log.Printf("Etiket hatası (%s): %v", name, err) + continue + } + createdTags = append(createdTags, tag) + } + + // 4. Postları Oluştur (40 adet) + postCount := 40 + titles := []string{ + "Go ile Web Geliştirme", "Gin Framework İpuçları", "Veritabanı Optimizasyonu", + "Modern Web Tasarımı", "Yapay Zeka Geleceği", "sağlıklı Yaşam Sırları", + "Dünya Turu Rehberi", "Lezzetli Pizza Tarifi", "Film İncelemesi: Joker", + "Müzik ve Ruh Hali", "Kodlama Pratikleri", "Cloud Computing Temelleri", + } + + images := []string{ + "https://images.unsplash.com/photo-1518770660439-4636190af475?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1504674900247-0877df9cc836?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1472214103451-9374bd1c798e?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1493770348161-369560ae357d?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1470225620780-dba8ba36b745?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1542291026-7eec264c27ff?auto=format&fit=crop&w=1000&q=80", + "https://images.unsplash.com/photo-1515378791036-0648a3ef77b2?auto=format&fit=crop&w=1000&q=80", + } + + // Resimlerin indirileceği hedef klasör + uploadDir := "uploads/posts" + if err := os.MkdirAll(uploadDir, os.ModePerm); err != nil { + log.Fatalf("Klasör oluşturulamadı: %v", err) + } + + log.Println("Postlar oluşturuluyor ve resimler indiriliyor...") + for i := 0; i < postCount; i++ { + // use local rand generator + title := fmt.Sprintf("%s %d", titles[r.Intn(len(titles))], i+1) + + // Resim İndirme İşlemi + remoteURL := images[r.Intn(len(images))] + fileName := fmt.Sprintf("seed-post-%d-%d.jpg", i+1, time.Now().Unix()) + filePath := filepath.Join(uploadDir, fileName) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + if err := downloadFile(filePath, remoteURL); err != nil { + log.Printf("Resim indirilemedi (%s): %v", remoteURL, err) + } + } + + dbImagePath := fmt.Sprintf("/uploads/posts/%s", fileName) + + post := models.Post{ + Title: title, + Slug: slugify(title), + Content: fmt.Sprintf("

Bu otomatik oluşturulmuş bir içeriktir: %s. Lorem ipsum dolor sit amet, consectetur adipiscing elit.

", title), + Images: dbImagePath, + } + + if err := db.Create(&post).Error; err != nil { + log.Printf("Post oluşturma hatası: %v", err) + continue + } + + // İlişkiler + numCats := r.Intn(3) + 1 + for j := 0; j < numCats; j++ { + cat := createdCategories[r.Intn(len(createdCategories))] + if err := db.Model(&post).Association("Categories").Append(&cat); err != nil { + log.Printf("Kategori ilişkilendirme hatası (post %d, cat %d): %v", post.ID, cat.ID, err) + } + } + + numTags := r.Intn(4) + 1 + for j := 0; j < numTags; j++ { + tag := createdTags[r.Intn(len(createdTags))] + if err := db.Model(&post).Association("Tags").Append(&tag); err != nil { + log.Printf("Tag ilişkilendirme hatası (post %d, tag %d): %v", post.ID, tag.ID, err) + } + } + + numComments := r.Intn(6) + for c := 0; c < numComments; c++ { + comment := models.Comment{ + PostID: post.ID, + UserID: user.ID, + Body: fmt.Sprintf("Bu harika bir yazı! Yorum #%d", c+1), + } + if err := db.Create(&comment).Error; err != nil { + log.Printf("Yorum oluşturma hatası: %v", err) + } + } + + time.Sleep(50 * time.Millisecond) + } + + log.Println("Tüm işlemler başarıyla tamamlandı!") +} + +func downloadFile(filepath string, url string) error { + resp, err := http.Get(url) + if err != nil { + return err + } + defer func() { + if cerr := resp.Body.Close(); cerr != nil { + log.Printf("error closing response body: %v", cerr) + } + }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bad status: %s", resp.Status) + } + + out, err := os.Create(filepath) + if err != nil { + return err + } + defer func() { + if cerr := out.Close(); cerr != nil { + log.Printf("error closing file: %v", cerr) + } + }() + + _, err = io.Copy(out, resp.Body) + return err +} + +func slugify(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, " ", "-") + s = strings.ReplaceAll(s, "ı", "i") + s = strings.ReplaceAll(s, "ğ", "g") + s = strings.ReplaceAll(s, "ü", "u") + s = strings.ReplaceAll(s, "ş", "s") + s = strings.ReplaceAll(s, "ö", "o") + s = strings.ReplaceAll(s, "ç", "c") + timestamp := time.Now().UnixNano() + return fmt.Sprintf("%s-%d", s, timestamp) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..11d5740 --- /dev/null +++ b/config/config.go @@ -0,0 +1,243 @@ +package configs + +import ( + "log" + "os" + "strconv" + "strings" + + "github.com/joho/godotenv" +) + +type Config struct { + Env string // örn. development, production + Port string + DBUrl string + JWTSecret string + AppURL string + ApiAppURL string // örn. https://api.example.com - e-posta doğrulama linkleri için kullanılır + GoogleClientID string + GoogleClientSecret string + GithubClientID string + GithubClientSecret string + GoogleRedirectURL string + GithubRedirectURL string + ClientCallbackURL string + OAuthRedirectURL string + RedisUrl string + AccessTokenExpireMinutes int + RefreshTokenExpireDays int + + // Avatar Ayarları + AvatarHeight int + AvatarWidth int + AvatarQuality int + AvatarFormat string + AvatarMode string // cover, contain, resize + + // Ana Sayfa Resim Ayarları + HomeImageHeight int + HomeImageWidth int + HomeImageQuality int + HomeImageFormat string + HomeImageMode string // cover, contain, resize + + // Hakkında Resim Ayarları + AboutImageHeight int + AboutImageWidth int + AboutImageQuality int + AboutImageFormat string + AboutImageMode string // cover, contain, resize + + // Servis Resim Ayarları + ServiceImageHeight int + ServiceImageWidth int + ServiceImageQuality int + ServiceImageFormat string + ServiceImageMode string // cover, contain, resize + + // Gönderi Resim Ayarları + PostImageHeight int + PostImageWidth int + PostImageQuality int + PostImageFormat string + PostImageMode string // cover, contain, resize + + // Gönderi Kategori Resim Ayarları + PostCategoryImageHeight int + PostCategoryImageWidth int + PostCategoryImageQuality int + PostCategoryImageFormat string + PostCategoryImageMode string // cover, contain, resize + + // Site Logo Ayarları + SettingsLogoHeight int + SettingsLogoWidth int + SettingsLogoQuality int + SettingsLogoFormat string + SettingsLogoMode string // cover, contain, resize + + // Afiş Resim Ayarları + BannerImageHeight int + BannerImageWidth int + BannerImageQuality int + BannerImageFormat string + BannerImageMode string // cover, contain, resize + + // Afiş Küçük Resim (Thumb) Ayarları + BannerThumbHeight int + BannerThumbWidth int + BannerThumbQuality int + BannerThumbFormat string + BannerThumbMode string // cover, contain, resize + + // E-posta Ayarları + EmailHost string + EmailPort string + EmailHostUser string + EmailHostPassword string + EmailFrom string + + // Debug / Logging + Debug bool // SET_DEBUG=true ise middleware ve diğer debug logları açılır + CorsDebug bool +} + +var AppConfig *Config + +func LoadConfig() { + err := godotenv.Load() + if err != nil { + // Eğer proje kök dizininden çalıştırılmıyorsa (örn: cmd/app içinden), üst dizinleri kontrol et + err = godotenv.Load(".env") + //err = godotenv.Load("../../.env") + } + if err != nil { + log.Printf("Uyarı: .env dosyası yüklenirken hata oluştu: %v — sistem ortam değişkenleriyle devam ediliyor", err) + } + log.Printf(".env ENV YUKLENDI") + AppConfig = &Config{ + Env: getEnv("APP_ENV", "development"), + Port: getEnv("PORT", "8080"), + DBUrl: getEnv("DB_URL", ""), + JWTSecret: getEnv("JWT_SECRET", "go-gin-mTFY2jAOMWWxadVIWjRoPG9aOM3z9srCVoU35Gs1VZaRKgXet26cztUE8LLpwok9"), + AppURL: getEnv("APP_URL", "http://localhost:8080"), + ApiAppURL: getEnv("BASE_SITE_URL", "http://localhost:3000"), + GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""), + GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""), + GithubClientID: getEnv("GITHUB_CLIENT_ID", ""), + GithubClientSecret: getEnv("GITHUB_CLIENT_SECRET", ""), + GoogleRedirectURL: getEnv("GOOGLE_REDIRECT_URL", "http://localhost:8080/api/v1/auth/google/callback"), + GithubRedirectURL: getEnv("GITHUB_REDIRECT_URL", "http://localhost:8080/api/v1/auth/github/callback"), + ClientCallbackURL: getEnv("CLIENT_CALLBACK_URL", ""), + OAuthRedirectURL: getEnv("OAUTH_REDIRECT_URL", ""), + RedisUrl: getEnv("REDIS_URL", ""), + AccessTokenExpireMinutes: getEnvAsInt("ACCESS_TOKEN_EXPIRE_MINUTES", 120), // Varsayılan 120 dakika + RefreshTokenExpireDays: getEnvAsInt("REFRESH_TOKEN_EXPIRE_DAYS", 30), // Varsayılan 30 gün + + // Avatar Varsayılanları + AvatarHeight: getEnvAsInt("AVATAR_H", 0), // Varsayılan 0 (otomatik) + AvatarWidth: getEnvAsInt("AVATAR_W", 800), // Varsayılan 800 + AvatarQuality: getEnvAsInt("AVATAR_Q", 80), // Varsayılan 80 + AvatarFormat: getEnv("AVATAR_F", "webp"), // Varsayılan webp + AvatarMode: getEnv("AVATAR_B", "contain"), // Varsayılan contain (Fit) + + // Ana Sayfa Resim Varsayılanları + HomeImageHeight: getEnvAsInt("HOME_IMAGE_H", 0), // Varsayılan 0 (otomatik) + HomeImageWidth: getEnvAsInt("HOME_IMAGE_W", 800), // Varsayılan 800 + HomeImageQuality: getEnvAsInt("HOME_IMAGE_Q", 80), // Varsayılan 80 + HomeImageFormat: getEnv("HOME_IMAGE_F", "webp"), // Varsayılan webp + HomeImageMode: getEnv("HOME_IMAGE_B", "contain"), // Varsayılan contain (Fit) + + // Hakkında Resim Varsayılanları + AboutImageHeight: getEnvAsInt("ABOUTME_IMAGE_H", getEnvAsInt("ABOUT_IMAGE_H", 0)), + AboutImageWidth: getEnvAsInt("ABOUTME_IMAGE_W", getEnvAsInt("ABOUT_IMAGE_W", 800)), + AboutImageQuality: getEnvAsInt("ABOUTME_IMAGE_Q", getEnvAsInt("ABOUT_IMAGE_Q", 80)), + AboutImageFormat: getEnv("ABOUTME_IMAGE_F", getEnv("ABOUT_IMAGE_F", "webp")), + AboutImageMode: getEnv("ABOUTME_IMAGE_B", getEnv("ABOUT_IMAGE_B", "contain")), + + // Servis Resim Varsayılanları + ServiceImageHeight: getEnvAsInt("SERVICE_IMAGE_H", 256), + ServiceImageWidth: getEnvAsInt("SERVICE_IMAGE_W", 256), + ServiceImageQuality: getEnvAsInt("SERVICE_IMAGE_Q", 90), + ServiceImageFormat: getEnv("SERVICE_IMAGE_F", "png"), + ServiceImageMode: getEnv("SERVICE_IMAGE_B", "cover"), + + // Gönderi Resim Varsayılanları + PostImageHeight: getEnvAsInt("POST_IMAGE_H", 450), + PostImageWidth: getEnvAsInt("POST_IMAGE_W", 700), + PostImageQuality: getEnvAsInt("POST_IMAGE_Q", 90), + PostImageFormat: getEnv("POST_IMAGE_F", "webp"), + PostImageMode: getEnv("POST_IMAGE_B", "cover"), + + // Gönderi Kategori Resim Varsayılanları + PostCategoryImageHeight: getEnvAsInt("POST_CATEGORY_IMAGE_H", 300), + PostCategoryImageWidth: getEnvAsInt("POST_CATEGORY_IMAGE_W", 300), + PostCategoryImageQuality: getEnvAsInt("POST_CATEGORY_IMAGE_Q", 85), + PostCategoryImageFormat: getEnv("POST_CATEGORY_IMAGE_F", "png"), + PostCategoryImageMode: getEnv("POST_CATEGORY_IMAGE_B", "cover"), + + // Site Logo Varsayılanları + SettingsLogoHeight: getEnvAsInt("SETTINGS_LOGO_H", 54), + SettingsLogoWidth: getEnvAsInt("SETTINGS_LOGO_W", 165), + SettingsLogoQuality: getEnvAsInt("SETTINGS_LOGO_Q", 85), + SettingsLogoFormat: getEnv("SETTINGS_LOGO_F", "png"), + SettingsLogoMode: getEnv("SETTINGS_LOGO_B", "cover"), + + // Afiş Resim Varsayılanları + BannerImageHeight: getEnvAsInt("BANNER_IMAGE_H", 700), + BannerImageWidth: getEnvAsInt("BANNER_IMAGE_W", 1920), + BannerImageQuality: getEnvAsInt("BANNER_IMAGE_Q", 85), + BannerImageFormat: getEnv("BANNER_IMAGE_F", "webp"), + BannerImageMode: getEnv("BANNER_IMAGE_B", "cover"), + + // Afiş Küçük Resim (Thumb) Varsayılanları + BannerThumbHeight: getEnvAsInt("BANNER_THUMB_H", 48), + BannerThumbWidth: getEnvAsInt("BANNER_THUMB_W", 48), + BannerThumbQuality: getEnvAsInt("BANNER_THUMB_Q", 90), + BannerThumbFormat: getEnv("BANNER_THUMB_F", "png"), + BannerThumbMode: getEnv("BANNER_THUMB_B", "cover"), + + // E-posta Varsayılanları + EmailHost: getEnv("EMAIL_HOST", "localhost"), + EmailPort: getEnv("EMAIL_PORT", "1025"), + EmailHostUser: getEnv("EMAIL_HOST_USER", ""), + EmailHostPassword: getEnv("EMAIL_HOST_PASSWORD", ""), + EmailFrom: getEnv("EMAIL_FROM", "noreply@gauth.local"), + + // Debug / Logging + Debug: getEnvAsBool("SET_DEBUG", false), + CorsDebug: getEnvAsBool("CORS_DEBUG", false), + } +} + +func getEnv(key, fallback string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return fallback +} + +func getEnvAsInt(key string, fallback int) int { + valueStr := getEnv(key, "") + if valueStr == "" { + return fallback + } + value, err := strconv.Atoi(valueStr) + if err != nil { + return fallback + } + return value +} + +func getEnvAsBool(key string, fallback bool) bool { + valueStr := strings.TrimSpace(getEnv(key, "")) + if valueStr == "" { + return fallback + } + value, err := strconv.ParseBool(valueStr) + if err != nil { + return fallback + } + return value +} diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..e222631 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,4629 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/api/v1/admin/categories": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of categories. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Admin: List categories (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new category (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Create a category", + "parameters": [ + { + "description": "Category payload", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CategoryPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.CategorySimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/deleted": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List categories that have been soft-deleted with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "List soft-deleted categories", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing category (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Update a category", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Category payload", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CategoryPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CategorySimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a category (admin)", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Delete a category", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}/posts": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a post and attach it to the given category", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Add a post to a category (admin)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Post payload", + "name": "post", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.PostPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a category and related posts/comments if soft-deleted", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Restore a soft-deleted category", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CategorySimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categoryviews": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of category views. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "categoryviews" + ], + "summary": "Admin: List category views (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a category view as admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "categoryviews" + ], + "summary": "Create a category view (admin)", + "parameters": [ + { + "description": "CategoryView payload", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CategoryViewPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.CategoryViewSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/comments": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of comments. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Admin: List comments (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a comment as admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Create a comment (admin)", + "parameters": [ + { + "description": "Comment payload", + "name": "comment", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CommentPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.CommentSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/comments/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update a comment as admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Update a comment (admin)", + "parameters": [ + { + "type": "integer", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Comment payload", + "name": "comment", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CommentPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CommentSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a comment as admin", + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Delete a comment (admin)", + "parameters": [ + { + "type": "integer", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/heroes": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of heroes. Use ?soft=only to list deleted, ?soft=with to include deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: List heroes", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new hero item (multipart/form-data)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Create a hero", + "parameters": [ + { + "type": "string", + "description": "Color", + "name": "color", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Text1", + "name": "text1", + "in": "formData" + }, + { + "type": "string", + "description": "Text2", + "name": "text2", + "in": "formData" + }, + { + "type": "string", + "description": "Text4", + "name": "text4", + "in": "formData" + }, + { + "type": "string", + "description": "Text5", + "name": "text5", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "integer", + "description": "Image width (frontend-provided)", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Image height (frontend-provided)", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Image quality (frontend-provided)", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "Image format (jpeg|png|webp) (frontend-provided)", + "name": "format", + "in": "formData" + }, + { + "type": "file", + "description": "Image file", + "name": "image", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/heroes/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return a single hero by id", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Get a hero by id", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing hero (multipart/form-data)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Update a hero", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Color", + "name": "color", + "in": "formData" + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Text1", + "name": "text1", + "in": "formData" + }, + { + "type": "string", + "description": "Text2", + "name": "text2", + "in": "formData" + }, + { + "type": "string", + "description": "Text4", + "name": "text4", + "in": "formData" + }, + { + "type": "string", + "description": "Text5", + "name": "text5", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "integer", + "description": "Image width (frontend-provided)", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Image height (frontend-provided)", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Image quality (frontend-provided)", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "Image format (jpeg|png|webp) (frontend-provided)", + "name": "format", + "in": "formData" + }, + { + "type": "file", + "description": "Image file", + "name": "image", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a hero by ID", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Delete a hero", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/heroes/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a soft-deleted hero by ID", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Restore a soft-deleted hero", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of posts. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Admin: List posts (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by category id", + "name": "category_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by tag id", + "name": "tag_id", + "in": "query" + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new blog post (supports multipart/form-data with image upload)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Create a post", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Slug", + "name": "slug", + "in": "formData" + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "formData" + }, + { + "type": "array", + "items": { + "type": "integer" + }, + "collectionFormat": "csv", + "description": "Category IDs (repeatable)", + "name": "category_ids", + "in": "formData" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Tag names (repeatable)", + "name": "tag_names", + "in": "formData" + }, + { + "type": "file", + "description": "Image files (use 'images' or 'image' fields)", + "name": "images", + "in": "formData" + }, + { + "type": "integer", + "description": "Image width", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Image height", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Image quality", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "Image format", + "name": "format", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/deleted": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List posts that have been soft-deleted with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "List soft-deleted posts", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing blog post (supports multipart/form-data with image upload)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Update a post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Slug", + "name": "slug", + "in": "formData" + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "formData" + }, + { + "type": "array", + "items": { + "type": "integer" + }, + "collectionFormat": "csv", + "description": "Category IDs (repeatable)", + "name": "category_ids", + "in": "formData" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Tag names (repeatable)", + "name": "tag_names", + "in": "formData" + }, + { + "type": "file", + "description": "Image files (use 'images' or 'image' fields)", + "name": "images", + "in": "formData" + }, + { + "type": "integer", + "description": "Image width", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Image height", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Image quality", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "Image format", + "name": "format", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a blog post by ID", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Delete a post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}/comments": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Add a comment to a specific post as admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Add a comment to a post (admin)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Comment payload", + "name": "comment", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CommentPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.CommentSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a post and its related comments (if soft-deleted)", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Restore a soft-deleted post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/settings": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of settings. Use ?soft=only to list deleted, ?soft=with to include deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: List settings", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new setting", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Create a setting", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Meta title", + "name": "meta_title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Meta description", + "name": "meta_description", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Phone", + "name": "phone", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "URL", + "name": "url", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Facebook", + "name": "facebook", + "in": "formData" + }, + { + "type": "string", + "description": "X", + "name": "x", + "in": "formData" + }, + { + "type": "string", + "description": "Instagram", + "name": "instagram", + "in": "formData" + }, + { + "type": "string", + "description": "Whatsapp", + "name": "whatsapp", + "in": "formData" + }, + { + "type": "string", + "description": "Pinterest", + "name": "pinterest", + "in": "formData" + }, + { + "type": "string", + "description": "Linkedin", + "name": "linkedin", + "in": "formData" + }, + { + "type": "string", + "description": "Slogan", + "name": "slogan", + "in": "formData" + }, + { + "type": "string", + "description": "Address", + "name": "address", + "in": "formData" + }, + { + "type": "string", + "description": "Copyright", + "name": "copyright", + "in": "formData" + }, + { + "type": "string", + "description": "Map embed", + "name": "map_embed", + "in": "formData" + }, + { + "type": "file", + "description": "White logo file upload (or provide w_logo path as string)", + "name": "w_logo", + "in": "formData" + }, + { + "type": "file", + "description": "Black logo file upload (or provide b_logo path as string)", + "name": "b_logo", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is active", + "name": "is_active", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo width", + "name": "w_width", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo height", + "name": "w_height", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo quality", + "name": "w_quality", + "in": "formData" + }, + { + "type": "string", + "description": "W logo format", + "name": "w_format", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo width", + "name": "b_width", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo height", + "name": "b_height", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo quality", + "name": "b_quality", + "in": "formData" + }, + { + "type": "string", + "description": "B logo format", + "name": "b_format", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/settings/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return a single setting by id", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Get a setting by id", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing setting", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Update a setting", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Meta title", + "name": "meta_title", + "in": "formData" + }, + { + "type": "string", + "description": "Meta description", + "name": "meta_description", + "in": "formData" + }, + { + "type": "string", + "description": "Phone", + "name": "phone", + "in": "formData" + }, + { + "type": "string", + "description": "URL", + "name": "url", + "in": "formData" + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData" + }, + { + "type": "string", + "description": "Facebook", + "name": "facebook", + "in": "formData" + }, + { + "type": "string", + "description": "X", + "name": "x", + "in": "formData" + }, + { + "type": "string", + "description": "Instagram", + "name": "instagram", + "in": "formData" + }, + { + "type": "string", + "description": "Whatsapp", + "name": "whatsapp", + "in": "formData" + }, + { + "type": "string", + "description": "Pinterest", + "name": "pinterest", + "in": "formData" + }, + { + "type": "string", + "description": "Linkedin", + "name": "linkedin", + "in": "formData" + }, + { + "type": "string", + "description": "Slogan", + "name": "slogan", + "in": "formData" + }, + { + "type": "string", + "description": "Address", + "name": "address", + "in": "formData" + }, + { + "type": "string", + "description": "Copyright", + "name": "copyright", + "in": "formData" + }, + { + "type": "string", + "description": "Map embed", + "name": "map_embed", + "in": "formData" + }, + { + "type": "file", + "description": "White logo file upload (or provide w_logo path as string)", + "name": "w_logo", + "in": "formData" + }, + { + "type": "file", + "description": "Black logo file upload (or provide b_logo path as string)", + "name": "b_logo", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is active", + "name": "is_active", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo width", + "name": "w_width", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo height", + "name": "w_height", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo quality", + "name": "w_quality", + "in": "formData" + }, + { + "type": "string", + "description": "W logo format", + "name": "w_format", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo width", + "name": "b_width", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo height", + "name": "b_height", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo quality", + "name": "b_quality", + "in": "formData" + }, + { + "type": "string", + "description": "B logo format", + "name": "b_format", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a setting by ID", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Delete a setting", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/settings/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a soft-deleted setting by ID", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Restore a soft-deleted setting", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of tags. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Admin: List tags (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "items: []AdminTagListItem (includes deleted_at when soft=only or soft=with)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new tag (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Create a tag", + "parameters": [ + { + "description": "Tag payload", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.TagPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing tag (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Update a tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Tag payload", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.TagPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a tag (admin)", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Delete a tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a tag that has been soft-deleted (admin)", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Restore a soft-deleted tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of users with pagination and search", + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: List users", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Search query (username or email)", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/users/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get user details by ID", + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: Get user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update user details (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: Update user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User update payload", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.AdminUserUpdatePayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft delete user", + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: Delete user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/users/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore soft-deleted user", + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: Restore user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/github": { + "get": { + "description": "Redirects to GitHub for authentication", + "tags": [ + "auth" + ], + "summary": "GitHub OAuth2 Login", + "responses": { + "302": { + "description": "Found" + } + } + } + }, + "/api/v1/auth/github/callback": { + "get": { + "description": "Handles GitHub OAuth2 callback", + "tags": [ + "auth" + ], + "summary": "GitHub OAuth2 Callback", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.AuthResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/google": { + "get": { + "description": "Redirects to Google for authentication", + "tags": [ + "auth" + ], + "summary": "Google OAuth2 Login", + "responses": { + "302": { + "description": "Found" + } + } + } + }, + "/api/v1/auth/google/callback": { + "get": { + "description": "Handles Google OAuth2 callback", + "tags": [ + "auth" + ], + "summary": "Google OAuth2 Callback", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.AuthResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "description": "Login with email and password, returns tokens", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login payload", + "name": "login", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.LoginPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get current authenticated user information", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user (me)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "description": "usage: send refresh token to get new access token and refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh token payload", + "name": "refresh", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RefreshPayload" + } + } + ], + "responses": { + "200": { + "description": "Returns both access_token and refresh_token", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Register a new user. Sends verification email. Does NOT return tokens.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "Register payload", + "name": "register", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RegisterPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/verify-email": { + "get": { + "description": "Verify email using token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verify email address", + "parameters": [ + { + "type": "string", + "description": "Verification Token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categories": { + "get": { + "description": "List categories with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "List categories", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categoryviews": { + "get": { + "description": "List category views with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "categoryviews" + ], + "summary": "List category views", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categoryviews/{id}": { + "get": { + "description": "Return a single category view by id", + "produces": [ + "application/json" + ], + "tags": [ + "categoryviews" + ], + "summary": "Get a category view by id", + "parameters": [ + { + "type": "integer", + "description": "CategoryView ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CategoryViewSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/comments": { + "get": { + "description": "List comments with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "List comments", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/comments/{id}": { + "get": { + "description": "Return a single comment by id", + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Get a comment by id", + "parameters": [ + { + "type": "integer", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CommentSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/heroes": { + "get": { + "description": "Return active heroes with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Public: List heroes", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/heroes/{id}": { + "get": { + "description": "Return a single hero by id", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Public: Get a hero by id", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/posts": { + "get": { + "description": "List posts with pagination and optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "List posts", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by category id", + "name": "category_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by tag id", + "name": "tag_id", + "in": "query" + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/posts/{slug}": { + "get": { + "description": "Return a single post found by slug", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Get a post by slug", + "parameters": [ + { + "type": "string", + "description": "Post slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings": { + "get": { + "description": "Return the active site setting (latest active). If none active, return latest setting.", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Public: Get site settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/tags": { + "get": { + "description": "List tags with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "List tags", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/tags/{id}": { + "get": { + "description": "Return a single tag by id", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Get a tag by id", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get profile of the logged-in user", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get current user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update profile of the logged-in user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update current user profile", + "parameters": [ + { + "description": "User update payload", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UserPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "controllers.AdminUserUpdatePayload": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "is_admin": { + "description": "Pointer allows checking if field is present", + "type": "boolean" + }, + "username": { + "type": "string" + } + } + }, + "controllers.AuthResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/controllers.UserResponse" + } + } + }, + "controllers.CategoryPayload": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "controllers.CategorySimple": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string", + "example": "news" + }, + "title": { + "type": "string", + "example": "News" + } + } + }, + "controllers.CategoryViewPayload": { + "type": "object", + "required": [ + "category_id" + ], + "properties": { + "category_id": { + "type": "integer" + }, + "ip_address": { + "type": "string" + } + } + }, + "controllers.CategoryViewSimple": { + "type": "object", + "properties": { + "category_id": { + "type": "integer", + "example": 1 + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer", + "example": 1 + }, + "ip_address": { + "type": "string", + "example": "127.0.0.1" + } + } + }, + "controllers.CommentPayload": { + "type": "object", + "required": [ + "body", + "post_id", + "user_id" + ], + "properties": { + "body": { + "type": "string" + }, + "post_id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "controllers.CommentSimple": { + "type": "object", + "properties": { + "body": { + "type": "string", + "example": "Nice post" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer", + "example": 1 + }, + "post_id": { + "type": "integer", + "example": 1 + }, + "user_id": { + "type": "integer", + "example": 2 + } + } + }, + "controllers.HeroListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "controllers.HeroResponse": { + "type": "object", + "properties": { + "color": { + "type": "string", + "example": "#ffffff" + }, + "created_at": { + "type": "string" + }, + "format": { + "type": "string", + "example": "jpeg" + }, + "height": { + "type": "integer", + "example": 1080 + }, + "id": { + "type": "integer", + "example": 1 + }, + "image": { + "type": "string", + "example": "/uploads/heroes/img.jpg" + }, + "is_active": { + "type": "boolean" + }, + "quality": { + "type": "integer", + "example": 80 + }, + "text1": { + "type": "string", + "example": "Kısa açıklama" + }, + "text2": { + "type": "string", + "example": "İkinci metin" + }, + "text4": { + "type": "string", + "example": "Yardımcı metin" + }, + "text5": { + "type": "string", + "example": "Ek metin" + }, + "title": { + "type": "string", + "example": "Hero Başlık" + }, + "updated_at": { + "type": "string" + }, + "width": { + "type": "integer", + "example": 1920 + } + } + }, + "controllers.LoginPayload": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "controllers.PostListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "controllers.PostPayload": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "category_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "type": "string" + }, + "images": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + } + } + }, + "controllers.PostResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.CategorySimple" + } + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer", + "example": 1 + }, + "images": { + "type": "string" + }, + "slug": { + "type": "string", + "example": "my-post-title" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "title": { + "type": "string", + "example": "My post title" + }, + "updated_at": { + "type": "string" + } + } + }, + "controllers.RefreshPayload": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "controllers.RegisterPayload": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 6 + }, + "username": { + "type": "string" + } + } + }, + "controllers.SettingListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "controllers.SettingResponse": { + "type": "object", + "properties": { + "address": { + "type": "string", + "example": "Adres örneği" + }, + "b_format": { + "type": "string" + }, + "b_height": { + "type": "integer" + }, + "b_logo": { + "type": "string" + }, + "b_quality": { + "type": "integer" + }, + "b_width": { + "type": "integer" + }, + "copyright": { + "type": "string", + "example": "© 2026 Example" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string", + "example": "info@example.com" + }, + "facebook": { + "type": "string", + "example": "https://facebook.com/example" + }, + "id": { + "type": "integer", + "example": 1 + }, + "instagram": { + "type": "string", + "example": "https://instagram.com/example" + }, + "is_active": { + "type": "boolean" + }, + "linkedin": { + "type": "string", + "example": "https://linkedin.com/company/example" + }, + "map_embed": { + "type": "string" + }, + "meta_description": { + "type": "string", + "example": "Site açıklaması" + }, + "meta_title": { + "type": "string", + "example": "Meta Başlık" + }, + "phone": { + "type": "string", + "example": " +90 555 555 55 55" + }, + "pinterest": { + "type": "string", + "example": "https://pinterest.com/example" + }, + "slogan": { + "type": "string", + "example": "En iyi içerik" + }, + "title": { + "type": "string", + "example": "Site Başlığı" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string", + "example": "https://example.com" + }, + "w_format": { + "type": "string" + }, + "w_height": { + "type": "integer" + }, + "w_logo": { + "type": "string" + }, + "w_quality": { + "type": "integer" + }, + "w_width": { + "description": "image transform / metadata fields (match app/database/models/setting.go)", + "type": "integer" + }, + "whatsapp": { + "type": "string", + "example": "https://wa.me/90555" + }, + "x": { + "type": "string", + "example": "https://x.com/example" + } + } + }, + "controllers.TagPayload": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "controllers.TagSimple": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "golang" + } + } + }, + "controllers.UserPayload": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "description": "Opsiyonel şifre güncellemesi", + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "controllers.UserResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "username": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Enter your JWT token (include \"Bearer \" prefix, e.g., \"Bearer \u003ctoken\u003e\")", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8080", + BasePath: "/", + Schemes: []string{}, + Title: "GoGin API", + Description: "API documentation for GoGin", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..0d83653 --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,4605 @@ +{ + "swagger": "2.0", + "info": { + "description": "API documentation for GoGin", + "title": "GoGin API", + "contact": {}, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/", + "paths": { + "/api/v1/admin/categories": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of categories. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Admin: List categories (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new category (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Create a category", + "parameters": [ + { + "description": "Category payload", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CategoryPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.CategorySimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/deleted": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List categories that have been soft-deleted with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "List soft-deleted categories", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing category (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Update a category", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Category payload", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CategoryPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CategorySimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a category (admin)", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Delete a category", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}/posts": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a post and attach it to the given category", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Add a post to a category (admin)", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Post payload", + "name": "post", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.PostPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categories/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a category and related posts/comments if soft-deleted", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "Restore a soft-deleted category", + "parameters": [ + { + "type": "integer", + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CategorySimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/categoryviews": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of category views. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "categoryviews" + ], + "summary": "Admin: List category views (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a category view as admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "categoryviews" + ], + "summary": "Create a category view (admin)", + "parameters": [ + { + "description": "CategoryView payload", + "name": "view", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CategoryViewPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.CategoryViewSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/comments": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of comments. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Admin: List comments (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a comment as admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Create a comment (admin)", + "parameters": [ + { + "description": "Comment payload", + "name": "comment", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CommentPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.CommentSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/comments/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update a comment as admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Update a comment (admin)", + "parameters": [ + { + "type": "integer", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Comment payload", + "name": "comment", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CommentPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CommentSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a comment as admin", + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Delete a comment (admin)", + "parameters": [ + { + "type": "integer", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/heroes": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of heroes. Use ?soft=only to list deleted, ?soft=with to include deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: List heroes", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new hero item (multipart/form-data)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Create a hero", + "parameters": [ + { + "type": "string", + "description": "Color", + "name": "color", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Text1", + "name": "text1", + "in": "formData" + }, + { + "type": "string", + "description": "Text2", + "name": "text2", + "in": "formData" + }, + { + "type": "string", + "description": "Text4", + "name": "text4", + "in": "formData" + }, + { + "type": "string", + "description": "Text5", + "name": "text5", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "integer", + "description": "Image width (frontend-provided)", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Image height (frontend-provided)", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Image quality (frontend-provided)", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "Image format (jpeg|png|webp) (frontend-provided)", + "name": "format", + "in": "formData" + }, + { + "type": "file", + "description": "Image file", + "name": "image", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/heroes/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return a single hero by id", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Get a hero by id", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing hero (multipart/form-data)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Update a hero", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Color", + "name": "color", + "in": "formData" + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Text1", + "name": "text1", + "in": "formData" + }, + { + "type": "string", + "description": "Text2", + "name": "text2", + "in": "formData" + }, + { + "type": "string", + "description": "Text4", + "name": "text4", + "in": "formData" + }, + { + "type": "string", + "description": "Text5", + "name": "text5", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is Active", + "name": "is_active", + "in": "formData" + }, + { + "type": "integer", + "description": "Image width (frontend-provided)", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Image height (frontend-provided)", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Image quality (frontend-provided)", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "Image format (jpeg|png|webp) (frontend-provided)", + "name": "format", + "in": "formData" + }, + { + "type": "file", + "description": "Image file", + "name": "image", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a hero by ID", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Delete a hero", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/heroes/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a soft-deleted hero by ID", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Admin: Restore a soft-deleted hero", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of posts. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Admin: List posts (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by category id", + "name": "category_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by tag id", + "name": "tag_id", + "in": "query" + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new blog post (supports multipart/form-data with image upload)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Create a post", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Slug", + "name": "slug", + "in": "formData" + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "formData" + }, + { + "type": "array", + "items": { + "type": "integer" + }, + "collectionFormat": "csv", + "description": "Category IDs (repeatable)", + "name": "category_ids", + "in": "formData" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Tag names (repeatable)", + "name": "tag_names", + "in": "formData" + }, + { + "type": "file", + "description": "Image files (use 'images' or 'image' fields)", + "name": "images", + "in": "formData" + }, + { + "type": "integer", + "description": "Image width", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Image height", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Image quality", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "Image format", + "name": "format", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/deleted": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "List posts that have been soft-deleted with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "List soft-deleted posts", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing blog post (supports multipart/form-data with image upload)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Update a post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Slug", + "name": "slug", + "in": "formData" + }, + { + "type": "string", + "description": "Content", + "name": "content", + "in": "formData" + }, + { + "type": "array", + "items": { + "type": "integer" + }, + "collectionFormat": "csv", + "description": "Category IDs (repeatable)", + "name": "category_ids", + "in": "formData" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv", + "description": "Tag names (repeatable)", + "name": "tag_names", + "in": "formData" + }, + { + "type": "file", + "description": "Image files (use 'images' or 'image' fields)", + "name": "images", + "in": "formData" + }, + { + "type": "integer", + "description": "Image width", + "name": "width", + "in": "formData" + }, + { + "type": "integer", + "description": "Image height", + "name": "height", + "in": "formData" + }, + { + "type": "integer", + "description": "Image quality", + "name": "quality", + "in": "formData" + }, + { + "type": "string", + "description": "Image format", + "name": "format", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a blog post by ID", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Delete a post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}/comments": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Add a comment to a specific post as admin", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Add a comment to a post (admin)", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Comment payload", + "name": "comment", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.CommentPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.CommentSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/posts/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a post and its related comments (if soft-deleted)", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Restore a soft-deleted post", + "parameters": [ + { + "type": "integer", + "description": "Post ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/settings": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of settings. Use ?soft=only to list deleted, ?soft=with to include deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: List settings", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new setting", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Create a setting", + "parameters": [ + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Meta title", + "name": "meta_title", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Meta description", + "name": "meta_description", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Phone", + "name": "phone", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "URL", + "name": "url", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "Facebook", + "name": "facebook", + "in": "formData" + }, + { + "type": "string", + "description": "X", + "name": "x", + "in": "formData" + }, + { + "type": "string", + "description": "Instagram", + "name": "instagram", + "in": "formData" + }, + { + "type": "string", + "description": "Whatsapp", + "name": "whatsapp", + "in": "formData" + }, + { + "type": "string", + "description": "Pinterest", + "name": "pinterest", + "in": "formData" + }, + { + "type": "string", + "description": "Linkedin", + "name": "linkedin", + "in": "formData" + }, + { + "type": "string", + "description": "Slogan", + "name": "slogan", + "in": "formData" + }, + { + "type": "string", + "description": "Address", + "name": "address", + "in": "formData" + }, + { + "type": "string", + "description": "Copyright", + "name": "copyright", + "in": "formData" + }, + { + "type": "string", + "description": "Map embed", + "name": "map_embed", + "in": "formData" + }, + { + "type": "file", + "description": "White logo file upload (or provide w_logo path as string)", + "name": "w_logo", + "in": "formData" + }, + { + "type": "file", + "description": "Black logo file upload (or provide b_logo path as string)", + "name": "b_logo", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is active", + "name": "is_active", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo width", + "name": "w_width", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo height", + "name": "w_height", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo quality", + "name": "w_quality", + "in": "formData" + }, + { + "type": "string", + "description": "W logo format", + "name": "w_format", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo width", + "name": "b_width", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo height", + "name": "b_height", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo quality", + "name": "b_quality", + "in": "formData" + }, + { + "type": "string", + "description": "B logo format", + "name": "b_format", + "in": "formData" + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/settings/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Return a single setting by id", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Get a setting by id", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing setting", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Update a setting", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Title", + "name": "title", + "in": "formData" + }, + { + "type": "string", + "description": "Meta title", + "name": "meta_title", + "in": "formData" + }, + { + "type": "string", + "description": "Meta description", + "name": "meta_description", + "in": "formData" + }, + { + "type": "string", + "description": "Phone", + "name": "phone", + "in": "formData" + }, + { + "type": "string", + "description": "URL", + "name": "url", + "in": "formData" + }, + { + "type": "string", + "description": "Email", + "name": "email", + "in": "formData" + }, + { + "type": "string", + "description": "Facebook", + "name": "facebook", + "in": "formData" + }, + { + "type": "string", + "description": "X", + "name": "x", + "in": "formData" + }, + { + "type": "string", + "description": "Instagram", + "name": "instagram", + "in": "formData" + }, + { + "type": "string", + "description": "Whatsapp", + "name": "whatsapp", + "in": "formData" + }, + { + "type": "string", + "description": "Pinterest", + "name": "pinterest", + "in": "formData" + }, + { + "type": "string", + "description": "Linkedin", + "name": "linkedin", + "in": "formData" + }, + { + "type": "string", + "description": "Slogan", + "name": "slogan", + "in": "formData" + }, + { + "type": "string", + "description": "Address", + "name": "address", + "in": "formData" + }, + { + "type": "string", + "description": "Copyright", + "name": "copyright", + "in": "formData" + }, + { + "type": "string", + "description": "Map embed", + "name": "map_embed", + "in": "formData" + }, + { + "type": "file", + "description": "White logo file upload (or provide w_logo path as string)", + "name": "w_logo", + "in": "formData" + }, + { + "type": "file", + "description": "Black logo file upload (or provide b_logo path as string)", + "name": "b_logo", + "in": "formData" + }, + { + "type": "boolean", + "description": "Is active", + "name": "is_active", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo width", + "name": "w_width", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo height", + "name": "w_height", + "in": "formData" + }, + { + "type": "integer", + "description": "W logo quality", + "name": "w_quality", + "in": "formData" + }, + { + "type": "string", + "description": "W logo format", + "name": "w_format", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo width", + "name": "b_width", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo height", + "name": "b_height", + "in": "formData" + }, + { + "type": "integer", + "description": "B logo quality", + "name": "b_quality", + "in": "formData" + }, + { + "type": "string", + "description": "B logo format", + "name": "b_format", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a setting by ID", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Delete a setting", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/settings/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a soft-deleted setting by ID", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Admin: Restore a soft-deleted setting", + "parameters": [ + { + "type": "integer", + "description": "Setting ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of tags. Use ?soft=only to list only deleted, ?soft=with to include deleted, omit to exclude deleted.", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Admin: List tags (supports soft-delete filter)", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "items: []AdminTagListItem (includes deleted_at when soft=only or soft=with)", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new tag (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Create a tag", + "parameters": [ + { + "description": "Tag payload", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.TagPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update an existing tag (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Update a tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Tag payload", + "name": "tag", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.TagPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft-delete a tag (admin)", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Delete a tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/tags/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore a tag that has been soft-deleted (admin)", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Restore a soft-deleted tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/users": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin listing of users with pagination and search", + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: List users", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "string", + "description": "Search query (username or email)", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "Soft delete filter: only|with", + "name": "soft", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/users/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get user details by ID", + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: Get user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update user details (admin)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: Update user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "User update payload", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.AdminUserUpdatePayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Soft delete user", + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: Delete user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/admin/users/{id}/restore": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Restore soft-deleted user", + "produces": [ + "application/json" + ], + "tags": [ + "users_admin" + ], + "summary": "Admin: Restore user", + "parameters": [ + { + "type": "integer", + "description": "User ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/github": { + "get": { + "description": "Redirects to GitHub for authentication", + "tags": [ + "auth" + ], + "summary": "GitHub OAuth2 Login", + "responses": { + "302": { + "description": "Found" + } + } + } + }, + "/api/v1/auth/github/callback": { + "get": { + "description": "Handles GitHub OAuth2 callback", + "tags": [ + "auth" + ], + "summary": "GitHub OAuth2 Callback", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.AuthResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/google": { + "get": { + "description": "Redirects to Google for authentication", + "tags": [ + "auth" + ], + "summary": "Google OAuth2 Login", + "responses": { + "302": { + "description": "Found" + } + } + } + }, + "/api/v1/auth/google/callback": { + "get": { + "description": "Handles Google OAuth2 callback", + "tags": [ + "auth" + ], + "summary": "Google OAuth2 Callback", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.AuthResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/login": { + "post": { + "description": "Login with email and password, returns tokens", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login payload", + "name": "login", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.LoginPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get current authenticated user information", + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Get current user (me)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/refresh": { + "post": { + "description": "usage: send refresh token to get new access token and refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh access token", + "parameters": [ + { + "description": "Refresh token payload", + "name": "refresh", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RefreshPayload" + } + } + ], + "responses": { + "200": { + "description": "Returns both access_token and refresh_token", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/register": { + "post": { + "description": "Register a new user. Sends verification email. Does NOT return tokens.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Register a new user", + "parameters": [ + { + "description": "Register payload", + "name": "register", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.RegisterPayload" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/controllers.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/auth/verify-email": { + "get": { + "description": "Verify email using token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Verify email address", + "parameters": [ + { + "type": "string", + "description": "Verification Token", + "name": "token", + "in": "query", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categories": { + "get": { + "description": "List categories with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "categories" + ], + "summary": "List categories", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categoryviews": { + "get": { + "description": "List category views with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "categoryviews" + ], + "summary": "List category views", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/categoryviews/{id}": { + "get": { + "description": "Return a single category view by id", + "produces": [ + "application/json" + ], + "tags": [ + "categoryviews" + ], + "summary": "Get a category view by id", + "parameters": [ + { + "type": "integer", + "description": "CategoryView ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CategoryViewSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/comments": { + "get": { + "description": "List comments with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "List comments", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/comments/{id}": { + "get": { + "description": "Return a single comment by id", + "produces": [ + "application/json" + ], + "tags": [ + "comments" + ], + "summary": "Get a comment by id", + "parameters": [ + { + "type": "integer", + "description": "Comment ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.CommentSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/heroes": { + "get": { + "description": "Return active heroes with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Public: List heroes", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/heroes/{id}": { + "get": { + "description": "Return a single hero by id", + "produces": [ + "application/json" + ], + "tags": [ + "heroes" + ], + "summary": "Public: Get a hero by id", + "parameters": [ + { + "type": "integer", + "description": "Hero ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/posts": { + "get": { + "description": "List posts with pagination and optional filters", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "List posts", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by category id", + "name": "category_id", + "in": "query" + }, + { + "type": "integer", + "description": "Filter by tag id", + "name": "tag_id", + "in": "query" + }, + { + "type": "string", + "description": "Search query", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/posts/{slug}": { + "get": { + "description": "Return a single post found by slug", + "produces": [ + "application/json" + ], + "tags": [ + "posts" + ], + "summary": "Get a post by slug", + "parameters": [ + { + "type": "string", + "description": "Post slug", + "name": "slug", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/settings": { + "get": { + "description": "Return the active site setting (latest active). If none active, return latest setting.", + "produces": [ + "application/json" + ], + "tags": [ + "settings" + ], + "summary": "Public: Get site settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/tags": { + "get": { + "description": "List tags with pagination", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "List tags", + "parameters": [ + { + "type": "integer", + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Items per page", + "name": "per_page", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/tags/{id}": { + "get": { + "description": "Return a single tag by id", + "produces": [ + "application/json" + ], + "tags": [ + "tags" + ], + "summary": "Get a tag by id", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + "/api/v1/users/profile": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get profile of the logged-in user", + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Get current user profile", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update profile of the logged-in user", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "users" + ], + "summary": "Update current user profile", + "parameters": [ + { + "description": "User update payload", + "name": "user", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/controllers.UserPayload" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/controllers.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "definitions": { + "controllers.AdminUserUpdatePayload": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "is_admin": { + "description": "Pointer allows checking if field is present", + "type": "boolean" + }, + "username": { + "type": "string" + } + } + }, + "controllers.AuthResponse": { + "type": "object", + "properties": { + "access_token": { + "type": "string" + }, + "refresh_token": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/controllers.UserResponse" + } + } + }, + "controllers.CategoryPayload": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "controllers.CategorySimple": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "parent_id": { + "type": "integer" + }, + "slug": { + "type": "string", + "example": "news" + }, + "title": { + "type": "string", + "example": "News" + } + } + }, + "controllers.CategoryViewPayload": { + "type": "object", + "required": [ + "category_id" + ], + "properties": { + "category_id": { + "type": "integer" + }, + "ip_address": { + "type": "string" + } + } + }, + "controllers.CategoryViewSimple": { + "type": "object", + "properties": { + "category_id": { + "type": "integer", + "example": 1 + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer", + "example": 1 + }, + "ip_address": { + "type": "string", + "example": "127.0.0.1" + } + } + }, + "controllers.CommentPayload": { + "type": "object", + "required": [ + "body", + "post_id", + "user_id" + ], + "properties": { + "body": { + "type": "string" + }, + "post_id": { + "type": "integer" + }, + "user_id": { + "type": "integer" + } + } + }, + "controllers.CommentSimple": { + "type": "object", + "properties": { + "body": { + "type": "string", + "example": "Nice post" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer", + "example": 1 + }, + "post_id": { + "type": "integer", + "example": 1 + }, + "user_id": { + "type": "integer", + "example": 2 + } + } + }, + "controllers.HeroListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.HeroResponse" + } + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "controllers.HeroResponse": { + "type": "object", + "properties": { + "color": { + "type": "string", + "example": "#ffffff" + }, + "created_at": { + "type": "string" + }, + "format": { + "type": "string", + "example": "jpeg" + }, + "height": { + "type": "integer", + "example": 1080 + }, + "id": { + "type": "integer", + "example": 1 + }, + "image": { + "type": "string", + "example": "/uploads/heroes/img.jpg" + }, + "is_active": { + "type": "boolean" + }, + "quality": { + "type": "integer", + "example": 80 + }, + "text1": { + "type": "string", + "example": "Kısa açıklama" + }, + "text2": { + "type": "string", + "example": "İkinci metin" + }, + "text4": { + "type": "string", + "example": "Yardımcı metin" + }, + "text5": { + "type": "string", + "example": "Ek metin" + }, + "title": { + "type": "string", + "example": "Hero Başlık" + }, + "updated_at": { + "type": "string" + }, + "width": { + "type": "integer", + "example": 1920 + } + } + }, + "controllers.LoginPayload": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "controllers.PostListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.PostResponse" + } + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "controllers.PostPayload": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "category_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "content": { + "type": "string" + }, + "images": { + "type": "string" + }, + "slug": { + "type": "string" + }, + "tag_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "title": { + "type": "string" + } + } + }, + "controllers.PostResponse": { + "type": "object", + "properties": { + "categories": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.CategorySimple" + } + }, + "content": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer", + "example": 1 + }, + "images": { + "type": "string" + }, + "slug": { + "type": "string", + "example": "my-post-title" + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.TagSimple" + } + }, + "title": { + "type": "string", + "example": "My post title" + }, + "updated_at": { + "type": "string" + } + } + }, + "controllers.RefreshPayload": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string" + } + } + }, + "controllers.RegisterPayload": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 6 + }, + "username": { + "type": "string" + } + } + }, + "controllers.SettingListResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/controllers.SettingResponse" + } + }, + "page": { + "type": "integer" + }, + "per_page": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "controllers.SettingResponse": { + "type": "object", + "properties": { + "address": { + "type": "string", + "example": "Adres örneği" + }, + "b_format": { + "type": "string" + }, + "b_height": { + "type": "integer" + }, + "b_logo": { + "type": "string" + }, + "b_quality": { + "type": "integer" + }, + "b_width": { + "type": "integer" + }, + "copyright": { + "type": "string", + "example": "© 2026 Example" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string", + "example": "info@example.com" + }, + "facebook": { + "type": "string", + "example": "https://facebook.com/example" + }, + "id": { + "type": "integer", + "example": 1 + }, + "instagram": { + "type": "string", + "example": "https://instagram.com/example" + }, + "is_active": { + "type": "boolean" + }, + "linkedin": { + "type": "string", + "example": "https://linkedin.com/company/example" + }, + "map_embed": { + "type": "string" + }, + "meta_description": { + "type": "string", + "example": "Site açıklaması" + }, + "meta_title": { + "type": "string", + "example": "Meta Başlık" + }, + "phone": { + "type": "string", + "example": " +90 555 555 55 55" + }, + "pinterest": { + "type": "string", + "example": "https://pinterest.com/example" + }, + "slogan": { + "type": "string", + "example": "En iyi içerik" + }, + "title": { + "type": "string", + "example": "Site Başlığı" + }, + "updated_at": { + "type": "string" + }, + "url": { + "type": "string", + "example": "https://example.com" + }, + "w_format": { + "type": "string" + }, + "w_height": { + "type": "integer" + }, + "w_logo": { + "type": "string" + }, + "w_quality": { + "type": "integer" + }, + "w_width": { + "description": "image transform / metadata fields (match app/database/models/setting.go)", + "type": "integer" + }, + "whatsapp": { + "type": "string", + "example": "https://wa.me/90555" + }, + "x": { + "type": "string", + "example": "https://x.com/example" + } + } + }, + "controllers.TagPayload": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "controllers.TagSimple": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "golang" + } + } + }, + "controllers.UserPayload": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "password": { + "description": "Opsiyonel şifre güncellemesi", + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "controllers.UserResponse": { + "type": "object", + "properties": { + "email": { + "type": "string" + }, + "email_verified": { + "type": "boolean" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "username": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Enter your JWT token (include \"Bearer \" prefix, e.g., \"Bearer \u003ctoken\u003e\")", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..703e1ab --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,3018 @@ +basePath: / +definitions: + controllers.AdminUserUpdatePayload: + properties: + email: + type: string + is_admin: + description: Pointer allows checking if field is present + type: boolean + username: + type: string + type: object + controllers.AuthResponse: + properties: + access_token: + type: string + refresh_token: + type: string + user: + $ref: '#/definitions/controllers.UserResponse' + type: object + controllers.CategoryPayload: + properties: + description: + type: string + parent_id: + type: integer + slug: + type: string + title: + type: string + required: + - title + type: object + controllers.CategorySimple: + properties: + id: + example: 1 + type: integer + parent_id: + type: integer + slug: + example: news + type: string + title: + example: News + type: string + type: object + controllers.CategoryViewPayload: + properties: + category_id: + type: integer + ip_address: + type: string + required: + - category_id + type: object + controllers.CategoryViewSimple: + properties: + category_id: + example: 1 + type: integer + created_at: + type: string + id: + example: 1 + type: integer + ip_address: + example: 127.0.0.1 + type: string + type: object + controllers.CommentPayload: + properties: + body: + type: string + post_id: + type: integer + user_id: + type: integer + required: + - body + - post_id + - user_id + type: object + controllers.CommentSimple: + properties: + body: + example: Nice post + type: string + created_at: + type: string + id: + example: 1 + type: integer + post_id: + example: 1 + type: integer + user_id: + example: 2 + type: integer + type: object + controllers.HeroListResponse: + properties: + items: + items: + $ref: '#/definitions/controllers.HeroResponse' + type: array + page: + type: integer + per_page: + type: integer + total: + type: integer + type: object + controllers.HeroResponse: + properties: + color: + example: '#ffffff' + type: string + created_at: + type: string + format: + example: jpeg + type: string + height: + example: 1080 + type: integer + id: + example: 1 + type: integer + image: + example: /uploads/heroes/img.jpg + type: string + is_active: + type: boolean + quality: + example: 80 + type: integer + text1: + example: Kısa açıklama + type: string + text2: + example: İkinci metin + type: string + text4: + example: Yardımcı metin + type: string + text5: + example: Ek metin + type: string + title: + example: Hero Başlık + type: string + updated_at: + type: string + width: + example: 1920 + type: integer + type: object + controllers.LoginPayload: + properties: + email: + type: string + password: + type: string + required: + - email + - password + type: object + controllers.PostListResponse: + properties: + items: + items: + $ref: '#/definitions/controllers.PostResponse' + type: array + page: + type: integer + per_page: + type: integer + total: + type: integer + type: object + controllers.PostPayload: + properties: + category_ids: + items: + type: integer + type: array + content: + type: string + images: + type: string + slug: + type: string + tag_names: + items: + type: string + type: array + title: + type: string + required: + - title + type: object + controllers.PostResponse: + properties: + categories: + items: + $ref: '#/definitions/controllers.CategorySimple' + type: array + content: + type: string + created_at: + type: string + id: + example: 1 + type: integer + images: + type: string + slug: + example: my-post-title + type: string + tags: + items: + $ref: '#/definitions/controllers.TagSimple' + type: array + title: + example: My post title + type: string + updated_at: + type: string + type: object + controllers.RefreshPayload: + properties: + refresh_token: + type: string + required: + - refresh_token + type: object + controllers.RegisterPayload: + properties: + email: + type: string + password: + minLength: 6 + type: string + username: + type: string + required: + - email + - password + - username + type: object + controllers.SettingListResponse: + properties: + items: + items: + $ref: '#/definitions/controllers.SettingResponse' + type: array + page: + type: integer + per_page: + type: integer + total: + type: integer + type: object + controllers.SettingResponse: + properties: + address: + example: Adres örneği + type: string + b_format: + type: string + b_height: + type: integer + b_logo: + type: string + b_quality: + type: integer + b_width: + type: integer + copyright: + example: © 2026 Example + type: string + created_at: + type: string + email: + example: info@example.com + type: string + facebook: + example: https://facebook.com/example + type: string + id: + example: 1 + type: integer + instagram: + example: https://instagram.com/example + type: string + is_active: + type: boolean + linkedin: + example: https://linkedin.com/company/example + type: string + map_embed: + type: string + meta_description: + example: Site açıklaması + type: string + meta_title: + example: Meta Başlık + type: string + phone: + example: ' +90 555 555 55 55' + type: string + pinterest: + example: https://pinterest.com/example + type: string + slogan: + example: En iyi içerik + type: string + title: + example: Site Başlığı + type: string + updated_at: + type: string + url: + example: https://example.com + type: string + w_format: + type: string + w_height: + type: integer + w_logo: + type: string + w_quality: + type: integer + w_width: + description: image transform / metadata fields (match app/database/models/setting.go) + type: integer + whatsapp: + example: https://wa.me/90555 + type: string + x: + example: https://x.com/example + type: string + type: object + controllers.TagPayload: + properties: + name: + type: string + required: + - name + type: object + controllers.TagSimple: + properties: + id: + example: 1 + type: integer + name: + example: golang + type: string + type: object + controllers.UserPayload: + properties: + email: + type: string + password: + description: Opsiyonel şifre güncellemesi + type: string + username: + type: string + type: object + controllers.UserResponse: + properties: + email: + type: string + email_verified: + type: boolean + id: + type: integer + is_admin: + type: boolean + username: + type: string + type: object +host: localhost:8080 +info: + contact: {} + description: API documentation for GoGin + title: GoGin API + version: "1.0" +paths: + /api/v1/admin/categories: + get: + description: Admin listing of categories. Use ?soft=only to list only deleted, + ?soft=with to include deleted, omit to exclude deleted. + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: 'Soft delete filter: only|with' + in: query + name: soft + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: List categories (supports soft-delete filter)' + tags: + - categories + post: + consumes: + - application/json + description: Create a new category (admin) + parameters: + - description: Category payload + in: body + name: category + required: true + schema: + $ref: '#/definitions/controllers.CategoryPayload' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.CategorySimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create a category + tags: + - categories + /api/v1/admin/categories/{id}: + delete: + description: Soft-delete a category (admin) + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete a category + tags: + - categories + put: + consumes: + - application/json + description: Update an existing category (admin) + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + - description: Category payload + in: body + name: category + required: true + schema: + $ref: '#/definitions/controllers.CategoryPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.CategorySimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update a category + tags: + - categories + /api/v1/admin/categories/{id}/posts: + post: + consumes: + - application/json + description: Create a post and attach it to the given category + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + - description: Post payload + in: body + name: post + required: true + schema: + $ref: '#/definitions/controllers.PostPayload' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.PostResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Add a post to a category (admin) + tags: + - categories + /api/v1/admin/categories/{id}/restore: + post: + description: Restore a category and related posts/comments if soft-deleted + parameters: + - description: Category ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.CategorySimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Restore a soft-deleted category + tags: + - categories + /api/v1/admin/categories/deleted: + get: + description: List categories that have been soft-deleted with pagination + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List soft-deleted categories + tags: + - categories + /api/v1/admin/categoryviews: + get: + description: Admin listing of category views. Use ?soft=only to list only deleted, + ?soft=with to include deleted, omit to exclude deleted. + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: 'Soft delete filter: only|with' + in: query + name: soft + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: List category views (supports soft-delete filter)' + tags: + - categoryviews + post: + consumes: + - application/json + description: Create a category view as admin + parameters: + - description: CategoryView payload + in: body + name: view + required: true + schema: + $ref: '#/definitions/controllers.CategoryViewPayload' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.CategoryViewSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create a category view (admin) + tags: + - categoryviews + /api/v1/admin/comments: + get: + description: Admin listing of comments. Use ?soft=only to list only deleted, + ?soft=with to include deleted, omit to exclude deleted. + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: 'Soft delete filter: only|with' + in: query + name: soft + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: List comments (supports soft-delete filter)' + tags: + - comments + post: + consumes: + - application/json + description: Create a comment as admin + parameters: + - description: Comment payload + in: body + name: comment + required: true + schema: + $ref: '#/definitions/controllers.CommentPayload' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.CommentSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create a comment (admin) + tags: + - comments + /api/v1/admin/comments/{id}: + delete: + description: Soft-delete a comment as admin + parameters: + - description: Comment ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete a comment (admin) + tags: + - comments + put: + consumes: + - application/json + description: Update a comment as admin + parameters: + - description: Comment ID + in: path + name: id + required: true + type: integer + - description: Comment payload + in: body + name: comment + required: true + schema: + $ref: '#/definitions/controllers.CommentPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.CommentSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update a comment (admin) + tags: + - comments + /api/v1/admin/heroes: + get: + description: Admin listing of heroes. Use ?soft=only to list deleted, ?soft=with + to include deleted. + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: 'Soft delete filter: only|with' + in: query + name: soft + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.HeroListResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: List heroes' + tags: + - heroes + post: + consumes: + - multipart/form-data + description: Create a new hero item (multipart/form-data) + parameters: + - description: Color + in: formData + name: color + required: true + type: string + - description: Title + in: formData + name: title + type: string + - description: Text1 + in: formData + name: text1 + type: string + - description: Text2 + in: formData + name: text2 + type: string + - description: Text4 + in: formData + name: text4 + type: string + - description: Text5 + in: formData + name: text5 + type: string + - description: Is Active + in: formData + name: is_active + type: boolean + - description: Image width (frontend-provided) + in: formData + name: width + type: integer + - description: Image height (frontend-provided) + in: formData + name: height + type: integer + - description: Image quality (frontend-provided) + in: formData + name: quality + type: integer + - description: Image format (jpeg|png|webp) (frontend-provided) + in: formData + name: format + type: string + - description: Image file + in: formData + name: image + type: file + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.HeroResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Create a hero' + tags: + - heroes + /api/v1/admin/heroes/{id}: + delete: + description: Soft-delete a hero by ID + parameters: + - description: Hero ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Delete a hero' + tags: + - heroes + get: + description: Return a single hero by id + parameters: + - description: Hero ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.HeroResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Get a hero by id' + tags: + - heroes + put: + consumes: + - multipart/form-data + description: Update an existing hero (multipart/form-data) + parameters: + - description: Hero ID + in: path + name: id + required: true + type: integer + - description: Color + in: formData + name: color + type: string + - description: Title + in: formData + name: title + type: string + - description: Text1 + in: formData + name: text1 + type: string + - description: Text2 + in: formData + name: text2 + type: string + - description: Text4 + in: formData + name: text4 + type: string + - description: Text5 + in: formData + name: text5 + type: string + - description: Is Active + in: formData + name: is_active + type: boolean + - description: Image width (frontend-provided) + in: formData + name: width + type: integer + - description: Image height (frontend-provided) + in: formData + name: height + type: integer + - description: Image quality (frontend-provided) + in: formData + name: quality + type: integer + - description: Image format (jpeg|png|webp) (frontend-provided) + in: formData + name: format + type: string + - description: Image file + in: formData + name: image + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.HeroResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Update a hero' + tags: + - heroes + /api/v1/admin/heroes/{id}/restore: + post: + description: Restore a soft-deleted hero by ID + parameters: + - description: Hero ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.HeroResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Restore a soft-deleted hero' + tags: + - heroes + /api/v1/admin/posts: + get: + description: Admin listing of posts. Use ?soft=only to list only deleted, ?soft=with + to include deleted, omit to exclude deleted. + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Filter by category id + in: query + name: category_id + type: integer + - description: Filter by tag id + in: query + name: tag_id + type: integer + - description: Search query + in: query + name: q + type: string + - description: 'Soft delete filter: only|with' + in: query + name: soft + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.PostListResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: List posts (supports soft-delete filter)' + tags: + - posts + post: + consumes: + - multipart/form-data + description: Create a new blog post (supports multipart/form-data with image + upload) + parameters: + - description: Title + in: formData + name: title + required: true + type: string + - description: Slug + in: formData + name: slug + type: string + - description: Content + in: formData + name: content + type: string + - collectionFormat: csv + description: Category IDs (repeatable) + in: formData + items: + type: integer + name: category_ids + type: array + - collectionFormat: csv + description: Tag names (repeatable) + in: formData + items: + type: string + name: tag_names + type: array + - description: Image files (use 'images' or 'image' fields) + in: formData + name: images + type: file + - description: Image width + in: formData + name: width + type: integer + - description: Image height + in: formData + name: height + type: integer + - description: Image quality + in: formData + name: quality + type: integer + - description: Image format + in: formData + name: format + type: string + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.PostResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create a post + tags: + - posts + /api/v1/admin/posts/{id}: + delete: + description: Delete a blog post by ID + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete a post + tags: + - posts + put: + consumes: + - multipart/form-data + description: Update an existing blog post (supports multipart/form-data with + image upload) + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + - description: Title + in: formData + name: title + type: string + - description: Slug + in: formData + name: slug + type: string + - description: Content + in: formData + name: content + type: string + - collectionFormat: csv + description: Category IDs (repeatable) + in: formData + items: + type: integer + name: category_ids + type: array + - collectionFormat: csv + description: Tag names (repeatable) + in: formData + items: + type: string + name: tag_names + type: array + - description: Image files (use 'images' or 'image' fields) + in: formData + name: images + type: file + - description: Image width + in: formData + name: width + type: integer + - description: Image height + in: formData + name: height + type: integer + - description: Image quality + in: formData + name: quality + type: integer + - description: Image format + in: formData + name: format + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.PostResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update a post + tags: + - posts + /api/v1/admin/posts/{id}/comments: + post: + consumes: + - application/json + description: Add a comment to a specific post as admin + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + - description: Comment payload + in: body + name: comment + required: true + schema: + $ref: '#/definitions/controllers.CommentPayload' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.CommentSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Add a comment to a post (admin) + tags: + - posts + /api/v1/admin/posts/{id}/restore: + post: + description: Restore a post and its related comments (if soft-deleted) + parameters: + - description: Post ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.PostResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Restore a soft-deleted post + tags: + - posts + /api/v1/admin/posts/deleted: + get: + description: List posts that have been soft-deleted with pagination + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.PostListResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: List soft-deleted posts + tags: + - posts + /api/v1/admin/settings: + get: + description: Admin listing of settings. Use ?soft=only to list deleted, ?soft=with + to include deleted. + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: 'Soft delete filter: only|with' + in: query + name: soft + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.SettingListResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: List settings' + tags: + - settings + post: + consumes: + - multipart/form-data + description: Create a new setting + parameters: + - description: Title + in: formData + name: title + required: true + type: string + - description: Meta title + in: formData + name: meta_title + required: true + type: string + - description: Meta description + in: formData + name: meta_description + required: true + type: string + - description: Phone + in: formData + name: phone + required: true + type: string + - description: URL + in: formData + name: url + required: true + type: string + - description: Email + in: formData + name: email + required: true + type: string + - description: Facebook + in: formData + name: facebook + type: string + - description: X + in: formData + name: x + type: string + - description: Instagram + in: formData + name: instagram + type: string + - description: Whatsapp + in: formData + name: whatsapp + type: string + - description: Pinterest + in: formData + name: pinterest + type: string + - description: Linkedin + in: formData + name: linkedin + type: string + - description: Slogan + in: formData + name: slogan + type: string + - description: Address + in: formData + name: address + type: string + - description: Copyright + in: formData + name: copyright + type: string + - description: Map embed + in: formData + name: map_embed + type: string + - description: White logo file upload (or provide w_logo path as string) + in: formData + name: w_logo + type: file + - description: Black logo file upload (or provide b_logo path as string) + in: formData + name: b_logo + type: file + - description: Is active + in: formData + name: is_active + type: boolean + - description: W logo width + in: formData + name: w_width + type: integer + - description: W logo height + in: formData + name: w_height + type: integer + - description: W logo quality + in: formData + name: w_quality + type: integer + - description: W logo format + in: formData + name: w_format + type: string + - description: B logo width + in: formData + name: b_width + type: integer + - description: B logo height + in: formData + name: b_height + type: integer + - description: B logo quality + in: formData + name: b_quality + type: integer + - description: B logo format + in: formData + name: b_format + type: string + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.SettingResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Create a setting' + tags: + - settings + /api/v1/admin/settings/{id}: + delete: + description: Soft-delete a setting by ID + parameters: + - description: Setting ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Delete a setting' + tags: + - settings + get: + description: Return a single setting by id + parameters: + - description: Setting ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.SettingResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Get a setting by id' + tags: + - settings + put: + consumes: + - multipart/form-data + description: Update an existing setting + parameters: + - description: Setting ID + in: path + name: id + required: true + type: integer + - description: Title + in: formData + name: title + type: string + - description: Meta title + in: formData + name: meta_title + type: string + - description: Meta description + in: formData + name: meta_description + type: string + - description: Phone + in: formData + name: phone + type: string + - description: URL + in: formData + name: url + type: string + - description: Email + in: formData + name: email + type: string + - description: Facebook + in: formData + name: facebook + type: string + - description: X + in: formData + name: x + type: string + - description: Instagram + in: formData + name: instagram + type: string + - description: Whatsapp + in: formData + name: whatsapp + type: string + - description: Pinterest + in: formData + name: pinterest + type: string + - description: Linkedin + in: formData + name: linkedin + type: string + - description: Slogan + in: formData + name: slogan + type: string + - description: Address + in: formData + name: address + type: string + - description: Copyright + in: formData + name: copyright + type: string + - description: Map embed + in: formData + name: map_embed + type: string + - description: White logo file upload (or provide w_logo path as string) + in: formData + name: w_logo + type: file + - description: Black logo file upload (or provide b_logo path as string) + in: formData + name: b_logo + type: file + - description: Is active + in: formData + name: is_active + type: boolean + - description: W logo width + in: formData + name: w_width + type: integer + - description: W logo height + in: formData + name: w_height + type: integer + - description: W logo quality + in: formData + name: w_quality + type: integer + - description: W logo format + in: formData + name: w_format + type: string + - description: B logo width + in: formData + name: b_width + type: integer + - description: B logo height + in: formData + name: b_height + type: integer + - description: B logo quality + in: formData + name: b_quality + type: integer + - description: B logo format + in: formData + name: b_format + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.SettingResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Update a setting' + tags: + - settings + /api/v1/admin/settings/{id}/restore: + post: + description: Restore a soft-deleted setting by ID + parameters: + - description: Setting ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.SettingResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Restore a soft-deleted setting' + tags: + - settings + /api/v1/admin/tags: + get: + description: Admin listing of tags. Use ?soft=only to list only deleted, ?soft=with + to include deleted, omit to exclude deleted. + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: 'Soft delete filter: only|with' + in: query + name: soft + type: string + produces: + - application/json + responses: + "200": + description: 'items: []AdminTagListItem (includes deleted_at when soft=only + or soft=with)' + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: List tags (supports soft-delete filter)' + tags: + - tags + post: + consumes: + - application/json + description: Create a new tag (admin) + parameters: + - description: Tag payload + in: body + name: tag + required: true + schema: + $ref: '#/definitions/controllers.TagPayload' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.TagSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Create a tag + tags: + - tags + /api/v1/admin/tags/{id}: + delete: + description: Soft-delete a tag (admin) + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Delete a tag + tags: + - tags + put: + consumes: + - application/json + description: Update an existing tag (admin) + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + - description: Tag payload + in: body + name: tag + required: true + schema: + $ref: '#/definitions/controllers.TagPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.TagSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update a tag + tags: + - tags + /api/v1/admin/tags/{id}/restore: + post: + description: Restore a tag that has been soft-deleted (admin) + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.TagSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Restore a soft-deleted tag + tags: + - tags + /api/v1/admin/users: + get: + description: Admin listing of users with pagination and search + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Search query (username or email) + in: query + name: q + type: string + - description: 'Soft delete filter: only|with' + in: query + name: soft + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: List users' + tags: + - users_admin + /api/v1/admin/users/{id}: + delete: + description: Soft delete user + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Delete user' + tags: + - users_admin + get: + description: Get user details by ID + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.UserResponse' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Get user' + tags: + - users_admin + put: + consumes: + - application/json + description: Update user details (admin) + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + - description: User update payload + in: body + name: user + required: true + schema: + $ref: '#/definitions/controllers.AdminUserUpdatePayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.UserResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Update user' + tags: + - users_admin + /api/v1/admin/users/{id}/restore: + post: + description: Restore soft-deleted user + parameters: + - description: User ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.UserResponse' + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: 'Admin: Restore user' + tags: + - users_admin + /api/v1/auth/github: + get: + description: Redirects to GitHub for authentication + responses: + "302": + description: Found + summary: GitHub OAuth2 Login + tags: + - auth + /api/v1/auth/github/callback: + get: + description: Handles GitHub OAuth2 callback + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.AuthResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: GitHub OAuth2 Callback + tags: + - auth + /api/v1/auth/google: + get: + description: Redirects to Google for authentication + responses: + "302": + description: Found + summary: Google OAuth2 Login + tags: + - auth + /api/v1/auth/google/callback: + get: + description: Handles Google OAuth2 callback + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.AuthResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Google OAuth2 Callback + tags: + - auth + /api/v1/auth/login: + post: + consumes: + - application/json + description: Login with email and password, returns tokens + parameters: + - description: Login payload + in: body + name: login + required: true + schema: + $ref: '#/definitions/controllers.LoginPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.AuthResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + summary: Login user + tags: + - auth + /api/v1/auth/me: + get: + description: Get current authenticated user information + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get current user (me) + tags: + - auth + /api/v1/auth/refresh: + post: + consumes: + - application/json + description: 'usage: send refresh token to get new access token and refresh + token' + parameters: + - description: Refresh token payload + in: body + name: refresh + required: true + schema: + $ref: '#/definitions/controllers.RefreshPayload' + produces: + - application/json + responses: + "200": + description: Returns both access_token and refresh_token + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + summary: Refresh access token + tags: + - auth + /api/v1/auth/register: + post: + consumes: + - application/json + description: Register a new user. Sends verification email. Does NOT return + tokens. + parameters: + - description: Register payload + in: body + name: register + required: true + schema: + $ref: '#/definitions/controllers.RegisterPayload' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/controllers.AuthResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: Register a new user + tags: + - auth + /api/v1/auth/verify-email: + get: + consumes: + - application/json + description: Verify email using token + parameters: + - description: Verification Token + in: query + name: token + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Verify email address + tags: + - auth + /api/v1/categories: + get: + description: List categories with pagination + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List categories + tags: + - categories + /api/v1/categoryviews: + get: + description: List category views with pagination + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List category views + tags: + - categoryviews + /api/v1/categoryviews/{id}: + get: + description: Return a single category view by id + parameters: + - description: CategoryView ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.CategoryViewSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get a category view by id + tags: + - categoryviews + /api/v1/comments: + get: + description: List comments with pagination + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List comments + tags: + - comments + /api/v1/comments/{id}: + get: + description: Return a single comment by id + parameters: + - description: Comment ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.CommentSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get a comment by id + tags: + - comments + /api/v1/heroes: + get: + description: Return active heroes with pagination + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.HeroListResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: 'Public: List heroes' + tags: + - heroes + /api/v1/heroes/{id}: + get: + description: Return a single hero by id + parameters: + - description: Hero ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.HeroResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: 'Public: Get a hero by id' + tags: + - heroes + /api/v1/posts: + get: + description: List posts with pagination and optional filters + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + - description: Filter by category id + in: query + name: category_id + type: integer + - description: Filter by tag id + in: query + name: tag_id + type: integer + - description: Search query + in: query + name: q + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.PostListResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List posts + tags: + - posts + /api/v1/posts/{slug}: + get: + description: Return a single post found by slug + parameters: + - description: Post slug + in: path + name: slug + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.PostResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get a post by slug + tags: + - posts + /api/v1/settings: + get: + description: Return the active site setting (latest active). If none active, + return latest setting. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.SettingResponse' + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: 'Public: Get site settings' + tags: + - settings + /api/v1/tags: + get: + description: List tags with pagination + parameters: + - description: Page number + in: query + name: page + type: integer + - description: Items per page + in: query + name: per_page + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + summary: List tags + tags: + - tags + /api/v1/tags/{id}: + get: + description: Return a single tag by id + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.TagSimple' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + additionalProperties: + type: string + type: object + summary: Get a tag by id + tags: + - tags + /api/v1/users/profile: + get: + description: Get profile of the logged-in user + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.UserResponse' + "401": + description: Unauthorized + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Get current user profile + tags: + - users + put: + consumes: + - application/json + description: Update profile of the logged-in user + parameters: + - description: User update payload + in: body + name: user + required: true + schema: + $ref: '#/definitions/controllers.UserPayload' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/controllers.UserResponse' + "400": + description: Bad Request + schema: + additionalProperties: + type: string + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: + type: string + type: object + security: + - BearerAuth: [] + summary: Update current user profile + tags: + - users +securityDefinitions: + BearerAuth: + description: Enter your JWT token (include "Bearer " prefix, e.g., "Bearer ") + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/frontend/.env b/frontend/.env new file mode 100644 index 0000000..3de06a8 --- /dev/null +++ b/frontend/.env @@ -0,0 +1,95 @@ +NEXT_AUTH_SECRET=bFcOqf37V1DgSxsibuZ79jSIaI4MZ9TCB1Y7iWZFJZrtZdUJapesSi0dXo2Bx8xY + +GITHUB_CLIENT_ID='Ov23liUt9B61O46Mdfm4' +GITHUB_CLIENT_SECRET='c7fc8dcb1b2c8f22120608425d07d5efd995baaf' +GITHUB_SCOPE=['user:email'] + +GOOGLE_CLIENT_ID='915364976256-691m0s87as2r5vdbqr96f6humblseobt.apps.googleusercontent.com' +GOOGLE_CLIENT_SECRET='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' +GOOGLE_REDIRECT_URL=http://localhost:8080/v1/auth/google/callback + +AUTH_NO_SECRET=MlBdj44xjhZIlxQIiz4ZuszB1yvRMW0A +BASE_API_URL=http://localhost:8080 +# BASE_API_URL=https://api.beyhano.com.tr +NEXT_PUBLIC_API_URL=http://localhost:8080 +BASE_SITE_URL=http://localhost:3000 +AUTH_NO_ORIGIN=http://localhost:3000 +BASE_SITE_NAME='Beyhan Oğur' +NODE_ENV='development' +NEXT_PORT=3000 +APP_TITLE='Beyhan Oğur' +NEXT_API_SECRET='6YdkEwOZC0j5K9a5vJtJQGIuwAoqGG4c' +# NUXT_PUBLIC_API_BASE=https://api.beyhano.com.tr +CLOUD_FLARE_SITE_KEY='0x4AAAAAACHzHKvlEwMamxCM' +CLOUD_FLARE_SECRET='0x4AAAAAACHzHHisTSFzGw15HvwXF3yXRIg' +# JWT için gizli anahtar. Güvenlik için bunu daha karmaşık bir değerle değiştirin. +JWT_SECRET="go-gin-mTFY2jAOMWWxadVIWjRoPG9aOM3z9srCVoU35Gs1VZaRKgXet26cztUE8LLpwok9" +#####################GO################### +### Db Configuration +DB_URL="gogin:gg7678290@tcp(10.80.80.70:3306)/gogin?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=30s&writeTimeout=30s&multiStatements=true" +########################## +# Redis Configuration +REDIS_HOST=10.80.80.70 +REDIS_PORT=6379 +REDIS_USER=default +REDIS_PASSWORD=gg7678290 +REDIS_URL=redis://default:gg7678290@10.80.80.70:6379/4 +############################# +# Email Settings (Mailpit) +EMAIL_HOST=10.80.80.70 +EMAIL_PORT=1025 +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" +EMAIL_USE_TLS=false +EMAIL_USE_SSL=false +EMAIL_FROM=noreply@gauth.local +############################# +# App Genel Ayarları +PORT=8080 +################################ +# AVATANE IMAGES +AVATAR_H=150 +AVATAR_W=150 +AVATAR_Q=90 +AVATAR_B=cover +AVATAR_F=webp +####################### +# Home IMAGES +HOME_IMAGE_H=400 +HOME_IMAGE_W=400 +HOME_IMAGE_Q=90 +HOME_IMAGE_B=cover +HOME_IMAGE_F=webp +####################### +# Aboutme IMAGES +ABOUTME_IMAGE_H=400 +ABOUTME_IMAGE_W=400 +ABOUTME_IMAGE_Q=90 +ABOUTME_IMAGE_B=cover +ABOUTME_IMAGE_F=webp +####################### +# MyService IMAGES +SERVICE_IMAGE_H=256 +SERVICE_IMAGE_W=256 +SERVICE_IMAGE_Q=90 +SERVICE_IMAGE_B=cover +SERVICE_IMAGE_F=webp +####################### +# BANNER IMAGES +BANNER_IMAGE_H=700 +BANNER_IMAGE_W=1920 +BANNER_IMAGE_Q=85 +BANNER_IMAGE_B=cover +BANNER_IMAGE_F=webp +################################ +################################ +SET_DEBUG=true +CORS_DEBUG=true +APP_ENV=development +VITE_API_BASE_URL=http://localhost:8080 +FRONTEND_URL=http://localhost:3000 +SESSION_SECRET=go-gin-mTFY2jAOMWWxadVIWjRoPG9aOM3z9srCVoU35Gs1VZaRKgXet26cztUE8LLpwok9 +CLIENT_SECRET='2222' +CLIENT_ID='2222' +ACCESS_TOKEN_EXPIRE_MINUTES=180 +REFRESH_TOKEN_EXPIRE_DAYS=60 diff --git a/frontend/.env.local b/frontend/.env.local new file mode 100644 index 0000000..5ea42e7 --- /dev/null +++ b/frontend/.env.local @@ -0,0 +1,11 @@ +# NextAuth +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET="bFcOqf37V1DgSxsibuZ79jSIaI4MZ9TCB1Y7iWZFJZrtZdUJapesSi0dXo2Bx8xY" + +# Backend API +NEXT_PUBLIC_API_URL=http://localhost:8080 +BASE_API_URL=http://localhost:8080 + +# Turnstile (Cloudflare) +NEXT_PUBLIC_TURNSTILE_SITEKEY="0x4AAAAAACHzHKvlEwMamxCM" +TURNSTILE_SECRET_KEY="0x4AAAAAACHzHHisTSFzGw15HvwXF3yXRIg" diff --git a/frontend/ADMIN_PANEL_GUIDE.md b/frontend/ADMIN_PANEL_GUIDE.md new file mode 100644 index 0000000..ff097fa --- /dev/null +++ b/frontend/ADMIN_PANEL_GUIDE.md @@ -0,0 +1,112 @@ +# Admin Paneli Geliştirme Kılavuzu + +Bu kılavuz, `admin_user.md` dosyasındaki gereksinimlere dayanarak **Next.js 16 + TypeScript + Tailwind v4 + shadcn** teknolojileriyle geliştirilecek Admin Paneli için mimari yapıyı ve geliştirme süreçlerini içerir. + +## 1. Teknoloji Yığını ve Kurulum + +Proje `frontend` klasörü altında yapılandırılmıştır. Gerekli tüm paketler (`package.json`) önceden yüklenmiştir: +- **Framework:** Next.js 16 (App Router) +- **UI:** React 19, Tailwind CSS v4, shadcn/ui +- **İkonlar:** lucide-react +- **Validasyon:** Zod +- **Auth:** NextAuth.js +- **Bildirimler:** SweetAlert2 +- **Güvenlik:** nextjs-turnstile (Cloudflare) + +### Kurulum ve Çalıştırma +```bash +cd frontend +npm install +npm run dev +``` + +## 2. Proje Klasör Yapısı (Önerilen) + +Admin paneli için aşağıdaki klasör yapısını takip edeceğiz: + +``` +frontend/ +├── app/ +│ ├── admin/ # Admin paneli rotaları +│ │ ├── layout.tsx # Admin layout (Sidebar, Header, Auth Check) +│ │ ├── dashboard/ # Dashboard (KPI kartları) +│ │ ├── users/ # Kullanıcı yönetimi +│ │ ├── products/ # Ürün yönetimi +│ │ └── settings/ # Ayarlar +│ ├── api/auth/[...nextauth]/ # NextAuth API rotası +│ └── globals.css # Global stiller (Tailwind) +├── components/ +│ ├── admin/ # Admin'e özel bileşenler +│ │ ├── sidebar.tsx +│ │ ├── header.tsx +│ │ ├── data-table.tsx # Reusable tablo yapısı +│ │ └── recent-sales.tsx +│ └── ui/ # shadcn bileşenleri (Button, Input, vb.) +├── lib/ +│ ├── utils.ts # cn() ve diğer yardımcılar +│ ├── auth.ts # NextAuth konfigürasyonu +│ └── db.ts # Veritabanı bağlantısı (veya API client) +├── actions/ # Server Actions (Zod validasyonlu) +│ ├── auth-actions.ts +│ └── user-actions.ts +└── public/ + └── admin/ # Admin ile ilgili statik dosyalar +``` + +## 3. Ortam Değişkenleri (.env) + +Kök dizindeki `.env` dosyasına aşağıdaki değişkenlerin eklenmesi gerekmektedir: + +```env +# NextAuth +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=gizli-bir-anahtar-olusturun + +# Backend API (Go) +NEXT_PUBLIC_API_URL=http://localhost:8080/api/v1 + +# Cloudflare Turnstile (Login güvenliği için) +NEXT_PUBLIC_TURNSTILE_SITEKEY=your-site-key +TURNSTILE_SECRET_KEY=your-secret-key +``` + +## 4. Geliştirme Adımları + +Geliştirme süreci aşağıdaki sırayla ilerleyecektir: + +1. **Temel Bileşenler:** shadcn kurulumunun doğrulanması ve temel bileşenlerin (Button, Input, Card, Form) eklenmesi. +2. **Auth Yapısı:** NextAuth yapılandırması ve `middleware.ts` ile `/admin` rotalarının korunması. +3. **Login Sayfası:** `/admin/login` sayfasının tasarımı ve Turnstile entegrasyonu. +4. **Admin Layout:** Sidebar ve Header içeren ana yerleşim düzeninin oluşturulması. +5. **Dashboard:** KPI kartları ve özet tabloların eklenmesi. +6. **CRUD Sayfaları:** Kullanıcılar (`/users`), Ürünler (`/products`) sayfalarının geliştirilmesi. + +## 5. Güvenlik ve Validasyon Kuralları + +- **Zod:** Tüm form verileri hem istemci (client) hem sunucu (server) tarafında Zod şemaları ile doğrulanmalıdır. +- **Server Actions:** Veri mutasyonları (Create, Update, Delete) Server Actions üzerinden yapılmalı ve oturum kontrolü içermelidir. +- **Role-Based Access:** Sadece `admin` veya `superadmin` rolüne sahip kullanıcılar `/admin` paneline erişebilmelidir. + +## 6. Örnek Kullanım (Server Action ile Form) + +```typescript +// actions/login.ts +"use server" +import { z } from "zod"; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string().min(6), +}); + +export async function loginAction(formData: FormData) { + const data = Object.fromEntries(formData); + const parsed = loginSchema.safeParse(data); + + if (!parsed.success) { + return { error: "Geçersiz veri" }; + } + + // Auth işlemleri... +} +``` diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/frontend/app/admin/categories/category-dialog.tsx b/frontend/app/admin/categories/category-dialog.tsx new file mode 100644 index 0000000..98cfb94 --- /dev/null +++ b/frontend/app/admin/categories/category-dialog.tsx @@ -0,0 +1,235 @@ +"use client" + +import { useState, useEffect } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Category } from "@/types/category" +import { categoryService } from "@/services/categoryService" +import { toast } from "sonner" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" + +const formSchema = z.object({ + title: z.string().min(2, { + message: "Başlık en az 2 karakter olmalıdır.", + }), + slug: z.string().min(2, { + message: "Slug en az 2 karakter olmalıdır.", + }), + description: z.string().optional(), + parent_id: z.string().optional(), // Select value is string, will convert to number +}) + +interface CategoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + category?: Category | null + onSuccess?: () => void +} + +function slugify(text: string) { + const trMap: { [key: string]: string } = { + 'ç': 'c', 'Ç': 'c', + 'ğ': 'g', 'Ğ': 'g', + 'ş': 's', 'Ş': 's', + 'ü': 'u', 'Ü': 'u', + 'ı': 'i', 'İ': 'i', + 'ö': 'o', 'Ö': 'o' + }; + return text + .replace(/[çÇğĞşŞüÜıİöÖ]/g, (char) => trMap[char] || char) + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric chars (except space and hyphen) + .trim() + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-'); // Remove duplicate hyphens +} + +export function CategoryDialog({ open, onOpenChange, category, onSuccess }: CategoryDialogProps) { + const [loading, setLoading] = useState(false) + const [categories, setCategories] = useState([]) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + title: "", + slug: "", + description: "", + parent_id: "0", + }, + }) + + // Watch title to auto-generate slug + const titleValue = form.watch("title") + useEffect(() => { + if (!category && titleValue) { // Only auto-generate if creating new category + const slug = slugify(titleValue) + form.setValue("slug", slug) + } + }, [titleValue, category, form]) + + // Fetch categories for check parent selection + useEffect(() => { + if (open) { + categoryService.getCategories(1, 100, "", "active") // Get active categories + .then(res => { + // Ensure we don't list the current category (recursion check) + const validCategories = category + ? (res.items || []).filter(c => c.id !== category.id) + : (res.items || []); + setCategories(validCategories) + }) + .catch(err => console.error(err)) + } + }, [open, category]) + + useEffect(() => { + if (category) { + form.reset({ + title: category.title, + slug: category.slug, + description: category.description || "", + parent_id: category.parent_id ? category.parent_id.toString() : "0", + }) + } else { + form.reset({ + title: "", + slug: "", + description: "", + parent_id: "0", + }) + } + }, [category, form, open]) + + const onSubmit = async (values: z.infer) => { + setLoading(true) + try { + const payload: Partial = { + title: values.title, + slug: values.slug, + description: values.description, + parent_id: values.parent_id && values.parent_id !== "0" ? parseInt(values.parent_id) : null, + } + + if (category) { + await categoryService.updateCategory(category.id, payload) + toast.success("Kategori güncellendi") + } else { + await categoryService.createCategory(payload) + toast.success("Kategori oluşturuldu") + } + onOpenChange(false) + if (onSuccess) { + onSuccess() + } + } catch (error: unknown) { + toast.error((error as Error).message || "Bir hata oluştu") + } finally { + setLoading(false) + } + } + + return ( + + + + {category ? "Kategori Düzenle" : "Yeni Kategori Ekle"} + +
+ + ( + + Başlık + + + + + + )} + /> + ( + + Slug (URL) + + + + + + )} + /> + ( + + Üst Kategori + + + + )} + /> + ( + + Açıklama + +