Commit 4cc0e852 by Augusto

Lists and players update

parent 83be1096
package config package config
import "os" import (
"os"
"strings"
)
type Config struct { type Config struct {
Port string Port string
...@@ -16,6 +19,7 @@ type Config struct { ...@@ -16,6 +19,7 @@ type Config struct {
ProviderUser string ProviderUser string
ProviderSecret string ProviderSecret string
UploadDir string UploadDir string
DisableAutoMigrate bool
} }
func Load() Config { func Load() Config {
...@@ -33,6 +37,7 @@ func Load() Config { ...@@ -33,6 +37,7 @@ func Load() Config {
ProviderUser: os.Getenv("ProviderUser"), ProviderUser: os.Getenv("ProviderUser"),
ProviderSecret: os.Getenv("ProviderSecret"), ProviderSecret: os.Getenv("ProviderSecret"),
UploadDir: envOrDefault("UPLOAD_DIR", "./uploads"), UploadDir: envOrDefault("UPLOAD_DIR", "./uploads"),
DisableAutoMigrate: envBool("DISABLE_AUTOMIGRATE"),
} }
} }
...@@ -42,3 +47,12 @@ func envOrDefault(key, def string) string { ...@@ -42,3 +47,12 @@ func envOrDefault(key, def string) string {
} }
return def return def
} }
func envBool(key string) bool {
v := os.Getenv(key)
if v == "" {
return false
}
v = strings.ToLower(v)
return v == "1" || v == "true" || v == "yes" || v == "y" || v == "on"
}
...@@ -106,6 +106,9 @@ func Connect(cfg config.Config) (*gorm.DB, error) { ...@@ -106,6 +106,9 @@ func Connect(cfg config.Config) (*gorm.DB, error) {
} }
registerNanoIDHook(db) registerNanoIDHook(db)
if cfg.DisableAutoMigrate {
return db, nil
}
// Ensure enum types exist before AutoMigrate creates columns that reference them. // Ensure enum types exist before AutoMigrate creates columns that reference them.
if err := ensureEnumType(db, "foot", []string{"left", "right", "both"}); err != nil { if err := ensureEnumType(db, "foot", []string{"left", "right", "both"}); err != nil {
...@@ -147,6 +150,7 @@ func Connect(cfg config.Config) (*gorm.DB, error) { ...@@ -147,6 +150,7 @@ func Connect(cfg config.Config) (*gorm.DB, error) {
&models.UserSession{}, &models.UserSession{},
&models.Area{}, &models.Area{},
&models.Position{}, &models.Position{},
&models.Team{},
&models.Player{}, &models.Player{},
&models.Coach{}, &models.Coach{},
&models.Agent{}, &models.Agent{},
......
...@@ -2,10 +2,12 @@ package handlers ...@@ -2,10 +2,12 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"ScoutSystemElite/internal/models"
"ScoutSystemElite/internal/services" "ScoutSystemElite/internal/services"
) )
...@@ -13,6 +15,10 @@ type ListHandler struct { ...@@ -13,6 +15,10 @@ type ListHandler struct {
service *services.ListService service *services.ListService
} }
type createListShareRequest struct {
UserID uint `json:"userId" binding:"required"`
}
func NewListHandler(db *gorm.DB) *ListHandler { func NewListHandler(db *gorm.DB) *ListHandler {
return &ListHandler{ return &ListHandler{
service: services.NewListService(db), service: services.NewListService(db),
...@@ -151,6 +157,69 @@ func (h *ListHandler) FindAll(c *gin.Context) { ...@@ -151,6 +157,69 @@ func (h *ListHandler) FindAll(c *gin.Context) {
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
// FindByPlayerID godoc
// @Summary Get lists by player ID
// @Description Get paginated lists (owned or shared) that contain the given player ID
// @Tags Lists
// @Security BearerAuth
// @Produce json
// @Param playerId path string true "Player ID"
// @Param type query string false "Filter by list type"
// @Param name query string false "Filter by name"
// @Param season query string false "Filter by season"
// @Param isActive query boolean false "Filter by active status"
// @Param limit query int false "Limit results"
// @Param offset query int false "Offset for pagination"
// @Param sortBy query string false "Sort field"
// @Param sortOrder query string false "Sort order (asc/desc)"
// @Success 200 {object} services.PaginatedListsResponse
// @Failure 400 {object} map[string]interface{} "Invalid query parameters"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Router /lists/by-player/{playerId} [get]
func (h *ListHandler) FindByPlayerID(c *gin.Context) {
userID, exists := c.Get("userId")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
playerID := c.Param("playerId")
if playerID == "" {
c.JSON(http.StatusBadRequest, gin.H{
"statusCode": http.StatusBadRequest,
"message": "Invalid playerId",
"errorCode": "VALIDATION_ERROR",
})
return
}
var query services.QueryListsRequest
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"statusCode": http.StatusBadRequest,
"message": "Invalid query parameters",
"errorCode": "VALIDATION_ERROR",
})
return
}
result, err := h.service.FindByPlayerID(userID.(uint), playerID, query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"statusCode": http.StatusInternalServerError,
"message": err.Error(),
"errorCode": "INTERNAL_ERROR",
})
return
}
c.JSON(http.StatusOK, result)
}
// FindSharedWithMe godoc // FindSharedWithMe godoc
// @Summary Get lists shared with the authenticated user // @Summary Get lists shared with the authenticated user
// @Description Get paginated list of player lists that have been shared with the authenticated user // @Description Get paginated list of player lists that have been shared with the authenticated user
...@@ -299,3 +368,132 @@ func (h *ListHandler) Delete(c *gin.Context) { ...@@ -299,3 +368,132 @@ func (h *ListHandler) Delete(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
// Share godoc
// @Summary Share a list
// @Description Share a list with another user
// @Tags Lists
// @Security BearerAuth
// @Accept json
// @Param id path string true "List ID"
// @Param request body createListShareRequest true "Share payload"
// @Success 204
// @Failure 400 {object} map[string]interface{} "Invalid request body"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 403 {object} map[string]interface{} "Forbidden"
// @Failure 404 {object} map[string]interface{} "List not found"
// @Router /lists/{id}/shares [post]
func (h *ListHandler) Share(c *gin.Context) {
userVal, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
actor, ok := userVal.(models.User)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
listID := c.Param("id")
var req createListShareRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"statusCode": http.StatusBadRequest,
"message": "Invalid request body",
"errorCode": "VALIDATION_ERROR",
})
return
}
if err := h.service.Share(actor, listID, req.UserID); err != nil {
code := http.StatusInternalServerError
errCode := "INTERNAL_ERROR"
if err.Error() == "list not found" {
code = http.StatusNotFound
errCode = "NOT_FOUND"
} else if err.Error() == "forbidden" {
code = http.StatusForbidden
errCode = "FORBIDDEN"
}
c.JSON(code, gin.H{
"statusCode": code,
"message": err.Error(),
"errorCode": errCode,
})
return
}
c.Status(http.StatusNoContent)
}
// Unshare godoc
// @Summary Unshare a list
// @Description Unshare a list from a user
// @Tags Lists
// @Security BearerAuth
// @Param id path string true "List ID"
// @Param userId path int true "User ID"
// @Success 204
// @Failure 400 {object} map[string]interface{} "Invalid user ID"
// @Failure 401 {object} map[string]interface{} "Unauthorized"
// @Failure 403 {object} map[string]interface{} "Forbidden"
// @Failure 404 {object} map[string]interface{} "List not found"
// @Router /lists/{id}/shares/{userId} [delete]
func (h *ListHandler) Unshare(c *gin.Context) {
userVal, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
actor, ok := userVal.(models.User)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
listID := c.Param("id")
uid, err := strconv.ParseUint(c.Param("userId"), 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"statusCode": http.StatusBadRequest,
"message": "Invalid user ID",
"errorCode": "VALIDATION_ERROR",
})
return
}
if err := h.service.Unshare(actor, listID, uint(uid)); err != nil {
code := http.StatusInternalServerError
errCode := "INTERNAL_ERROR"
if err.Error() == "list not found" {
code = http.StatusNotFound
errCode = "NOT_FOUND"
} else if err.Error() == "forbidden" {
code = http.StatusForbidden
errCode = "FORBIDDEN"
}
c.JSON(code, gin.H{
"statusCode": code,
"message": err.Error(),
"errorCode": errCode,
})
return
}
c.Status(http.StatusNoContent)
}
...@@ -8,6 +8,7 @@ import ( ...@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"ScoutSystemElite/internal/models"
"ScoutSystemElite/internal/services" "ScoutSystemElite/internal/services"
) )
...@@ -33,6 +34,44 @@ func NewPlayerHandler(db *gorm.DB) *PlayerHandler { ...@@ -33,6 +34,44 @@ func NewPlayerHandler(db *gorm.DB) *PlayerHandler {
// @Failure 400 {object} map[string]interface{} "Invalid request" // @Failure 400 {object} map[string]interface{} "Invalid request"
// @Router /players [post] // @Router /players [post]
func (h *PlayerHandler) Save(c *gin.Context) { func (h *PlayerHandler) Save(c *gin.Context) {
userVal, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
actor, ok := userVal.(models.User)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
userIDVal, exists := c.Get("userId")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
userID, ok := userIDVal.(uint)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
var req services.CreatePlayerRequest var req services.CreatePlayerRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
...@@ -43,7 +82,7 @@ func (h *PlayerHandler) Save(c *gin.Context) { ...@@ -43,7 +82,7 @@ func (h *PlayerHandler) Save(c *gin.Context) {
return return
} }
player, err := h.service.UpsertByWyID(req) player, err := h.service.UpsertByWyID(userID, actor.Role, req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"statusCode": http.StatusInternalServerError, "statusCode": http.StatusInternalServerError,
...@@ -221,6 +260,8 @@ func (h *PlayerHandler) GetByProviderID(c *gin.Context) { ...@@ -221,6 +260,8 @@ func (h *PlayerHandler) GetByProviderID(c *gin.Context) {
// @Param teamWyId query int false "Filter by team WyID" // @Param teamWyId query int false "Filter by team WyID"
// @Param nationalityWyId query int false "Filter by nationality WyID" // @Param nationalityWyId query int false "Filter by nationality WyID"
// @Param positions query string false "Filter by positions (comma-separated)" // @Param positions query string false "Filter by positions (comma-separated)"
// @Param createdByUserId query int false "Filter by createdByUserId"
// @Param updatedByUserId query int false "Filter by updatedByUserId"
// @Param minAge query int false "Minimum age" // @Param minAge query int false "Minimum age"
// @Param maxAge query int false "Maximum age" // @Param maxAge query int false "Maximum age"
// @Param minHeight query int false "Minimum height in cm" // @Param minHeight query int false "Minimum height in cm"
...@@ -272,6 +313,25 @@ func (h *PlayerHandler) FindAll(c *gin.Context) { ...@@ -272,6 +313,25 @@ func (h *PlayerHandler) FindAll(c *gin.Context) {
// @Failure 404 {object} map[string]interface{} "Player not found" // @Failure 404 {object} map[string]interface{} "Player not found"
// @Router /players/{id} [patch] // @Router /players/{id} [patch]
func (h *PlayerHandler) UpdateByID(c *gin.Context) { func (h *PlayerHandler) UpdateByID(c *gin.Context) {
userVal, exists := c.Get("user")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
actor, ok := userVal.(models.User)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{
"statusCode": http.StatusUnauthorized,
"message": "Unauthorized",
"errorCode": "UNAUTHORIZED",
})
return
}
id := c.Param("id") id := c.Param("id")
var req services.UpdatePlayerRequest var req services.UpdatePlayerRequest
...@@ -284,7 +344,7 @@ func (h *PlayerHandler) UpdateByID(c *gin.Context) { ...@@ -284,7 +344,7 @@ func (h *PlayerHandler) UpdateByID(c *gin.Context) {
return return
} }
player, err := h.service.UpdateByID(id, req) player, err := h.service.UpdateByID(actor.ID, actor.Role, id, req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"statusCode": http.StatusInternalServerError, "statusCode": http.StatusInternalServerError,
......
...@@ -7,9 +7,12 @@ import ( ...@@ -7,9 +7,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"ScoutSystemElite/internal/models"
"ScoutSystemElite/internal/services" "ScoutSystemElite/internal/services"
) )
var _ = models.ClientModule{}
type SuperAdminHandler struct { type SuperAdminHandler struct {
service *services.SuperAdminService service *services.SuperAdminService
} }
......
package handlers
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
"ScoutSystemElite/internal/services"
)
type TeamHandler struct {
service *services.TeamService
}
func NewTeamHandler(db *gorm.DB) *TeamHandler {
return &TeamHandler{
service: services.NewTeamService(db),
}
}
// Create godoc
// @Summary Create a new team
// @Description Create a new team
// @Tags Teams
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param request body services.CreateTeamRequest true "Team data"
// @Success 201 {object} services.TeamResponse
// @Failure 400 {object} map[string]interface{} "Invalid request"
// @Router /teams [post]
func (h *TeamHandler) Create(c *gin.Context) {
var req services.CreateTeamRequest
if err := c.ShouldBindBodyWithJSON(&req); err != nil {
log.Printf("teams.Create: invalid request body: %v", err)
if ve, ok := err.(validator.ValidationErrors); ok {
errs := make([]gin.H, 0, len(ve))
for _, fe := range ve {
errs = append(errs, gin.H{
"field": fe.Field(),
"tag": fe.Tag(),
})
}
c.JSON(http.StatusBadRequest, gin.H{
"statusCode": http.StatusBadRequest,
"message": "Invalid request body",
"errorCode": "VALIDATION_ERROR",
"details": errs,
})
return
}
c.JSON(http.StatusBadRequest, gin.H{
"statusCode": http.StatusBadRequest,
"message": "Invalid request body",
"errorCode": "VALIDATION_ERROR",
"details": err.Error(),
})
return
}
team, err := h.service.Create(req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"statusCode": http.StatusInternalServerError,
"message": err.Error(),
"errorCode": "INTERNAL_ERROR",
})
return
}
c.JSON(http.StatusCreated, team)
}
// FindAll godoc
// @Summary Get all teams
// @Description Get paginated list of teams with optional filters
// @Tags Teams
// @Security BearerAuth
// @Produce json
// @Param name query string false "Filter by name"
// @Param type query string false "Filter by type"
// @Param category query string false "Filter by category"
// @Param gender query string false "Filter by gender"
// @Param areaWyId query int false "Filter by area WyID"
// @Param isActive query boolean false "Filter by active status"
// @Param limit query int false "Limit results" default(100)
// @Param offset query int false "Offset for pagination" default(0)
// @Param sortBy query string false "Sort field"
// @Param sortOrder query string false "Sort order (asc/desc)"
// @Success 200 {object} services.PaginatedTeamsResponse
// @Failure 400 {object} map[string]interface{} "Invalid query parameters"
// @Router /teams [get]
func (h *TeamHandler) FindAll(c *gin.Context) {
var query services.QueryTeamsRequest
if err := c.ShouldBindQuery(&query); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"statusCode": http.StatusBadRequest,
"message": "Invalid query parameters",
"errorCode": "VALIDATION_ERROR",
})
return
}
result, err := h.service.FindAll(query)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"statusCode": http.StatusInternalServerError,
"message": err.Error(),
"errorCode": "INTERNAL_ERROR",
})
return
}
c.JSON(http.StatusOK, result)
}
// GetByID godoc
// @Summary Get team by ID
// @Description Get a single team by its ID
// @Tags Teams
// @Security BearerAuth
// @Produce json
// @Param id path string true "Team ID"
// @Success 200 {object} services.TeamResponse
// @Failure 404 {object} map[string]interface{} "Team not found"
// @Router /teams/{id} [get]
func (h *TeamHandler) GetByID(c *gin.Context) {
id := c.Param("id")
team, err := h.service.FindByID(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"statusCode": http.StatusInternalServerError,
"message": err.Error(),
"errorCode": "INTERNAL_ERROR",
})
return
}
if team == nil {
c.JSON(http.StatusNotFound, gin.H{
"statusCode": http.StatusNotFound,
"message": "Team not found",
"errorCode": "NOT_FOUND",
})
return
}
c.JSON(http.StatusOK, team)
}
// UpdateByID godoc
// @Summary Update a team
// @Description Update an existing team's information
// @Tags Teams
// @Security BearerAuth
// @Accept json
// @Produce json
// @Param id path string true "Team ID"
// @Param request body services.UpdateTeamRequest true "Team data"
// @Success 200 {object} services.TeamResponse
// @Failure 400 {object} map[string]interface{} "Invalid request"
// @Failure 404 {object} map[string]interface{} "Team not found"
// @Router /teams/{id} [patch]
func (h *TeamHandler) UpdateByID(c *gin.Context) {
id := c.Param("id")
var req services.UpdateTeamRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"statusCode": http.StatusBadRequest,
"message": "Invalid request body",
"errorCode": "VALIDATION_ERROR",
})
return
}
team, err := h.service.UpdateByID(id, req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"statusCode": http.StatusInternalServerError,
"message": err.Error(),
"errorCode": "INTERNAL_ERROR",
})
return
}
if team == nil {
c.JSON(http.StatusNotFound, gin.H{
"statusCode": http.StatusNotFound,
"message": "Team not found",
"errorCode": "NOT_FOUND",
})
return
}
c.JSON(http.StatusOK, team)
}
// DeleteByID godoc
// @Summary Delete a team
// @Description Soft delete a team
// @Tags Teams
// @Security BearerAuth
// @Param id path string true "Team ID"
// @Success 204
// @Failure 404 {object} map[string]interface{} "Team not found"
// @Router /teams/{id} [delete]
func (h *TeamHandler) DeleteByID(c *gin.Context) {
id := c.Param("id")
if err := h.service.DeleteByID(id); err != nil {
code := http.StatusInternalServerError
errCode := "INTERNAL_ERROR"
if err.Error() == "team not found" {
code = http.StatusNotFound
errCode = "NOT_FOUND"
}
c.JSON(code, gin.H{
"statusCode": code,
"message": err.Error(),
"errorCode": errCode,
})
return
}
c.Status(http.StatusNoContent)
}
...@@ -100,6 +100,26 @@ type Position struct { ...@@ -100,6 +100,26 @@ type Position struct {
func (Position) TableName() string { return "positions" } func (Position) TableName() string { return "positions" }
type Team struct {
ID string `gorm:"primaryKey;size:16;column:id" json:"id"`
Name string `gorm:"column:name;not null;index:idx_teams_name" json:"name"`
OfficialName *string `gorm:"column:official_name" json:"officialName"`
ShortName *string `gorm:"column:short_name" json:"shortName"`
Description *string `gorm:"column:description" json:"description"`
Type string `gorm:"column:type;default:club" json:"type"`
Category string `gorm:"column:category;default:default" json:"category"`
Gender *string `gorm:"column:gender" json:"gender"`
AreaWyID *int `gorm:"column:area_wy_id;index:idx_teams_area_wy_id" json:"areaWyId"`
City *string `gorm:"column:city" json:"city"`
ImageDataURL *string `gorm:"column:image_data_url" json:"imageDataUrl"`
IsActive bool `gorm:"column:is_active;default:true;index:idx_teams_is_active" json:"isActive"`
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt *time.Time `gorm:"column:deleted_at;index:idx_teams_deleted_at" json:"deletedAt"`
}
func (Team) TableName() string { return "teams" }
type Player struct { type Player struct {
ID string `gorm:"primaryKey;size:16;column:id" json:"id"` ID string `gorm:"primaryKey;size:16;column:id" json:"id"`
WyID *int `gorm:"column:wy_id;uniqueIndex" json:"wyId"` WyID *int `gorm:"column:wy_id;uniqueIndex" json:"wyId"`
...@@ -107,6 +127,8 @@ type Player struct { ...@@ -107,6 +127,8 @@ type Player struct {
TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"` TeamWyID *int `gorm:"column:team_wy_id" json:"teamWyId"`
TeamTsID *string `gorm:"column:team_ts_id;size:64" json:"teamTsId"` TeamTsID *string `gorm:"column:team_ts_id;size:64" json:"teamTsId"`
CountryTsID *string `gorm:"column:country_ts_id;size:64" json:"countryTsId"` CountryTsID *string `gorm:"column:country_ts_id;size:64" json:"countryTsId"`
CreatedByUserID *uint `gorm:"column:created_by_user_id;index:idx_players_created_by_user_id" json:"createdByUserId"`
UpdatedByUserID *uint `gorm:"column:updated_by_user_id;index:idx_players_updated_by_user_id" json:"updatedByUserId"`
FirstName string `gorm:"column:first_name" json:"firstName"` FirstName string `gorm:"column:first_name" json:"firstName"`
LastName string `gorm:"column:last_name" json:"lastName"` LastName string `gorm:"column:last_name" json:"lastName"`
MiddleName *string `gorm:"column:middle_name" json:"middleName"` MiddleName *string `gorm:"column:middle_name" json:"middleName"`
......
...@@ -320,10 +320,25 @@ func New(db *gorm.DB, cfg config.Config) *gin.Engine { ...@@ -320,10 +320,25 @@ func New(db *gorm.DB, cfg config.Config) *gin.Engine {
{ {
listsGroup.POST("", listHandler.Create) listsGroup.POST("", listHandler.Create)
listsGroup.GET("", listHandler.FindAll) listsGroup.GET("", listHandler.FindAll)
listsGroup.GET("/by-player/:playerId", listHandler.FindByPlayerID)
listsGroup.GET("/shared-with-me", listHandler.FindSharedWithMe) listsGroup.GET("/shared-with-me", listHandler.FindSharedWithMe)
listsGroup.GET("/:id", listHandler.GetByID) listsGroup.GET("/:id", listHandler.GetByID)
listsGroup.PATCH("/:id", listHandler.Update) listsGroup.PATCH("/:id", listHandler.Update)
listsGroup.DELETE("/:id", listHandler.Delete) listsGroup.DELETE("/:id", listHandler.Delete)
listsGroup.POST("/:id/shares", listHandler.Share)
listsGroup.DELETE("/:id/shares/:userId", listHandler.Unshare)
}
// Teams routes
teamHandler := handlers.NewTeamHandler(db)
teamsGroup := api.Group("/teams")
teamsGroup.Use(jwtMiddleware)
{
teamsGroup.POST("", teamHandler.Create)
teamsGroup.GET("", teamHandler.FindAll)
teamsGroup.GET("/:id", teamHandler.GetByID)
teamsGroup.PATCH("/:id", teamHandler.UpdateByID)
teamsGroup.DELETE("/:id", teamHandler.DeleteByID)
} }
// Positions routes // Positions routes
......
...@@ -94,12 +94,20 @@ func (s *ListService) Create(userID uint, req CreateListRequest) (*ListResponse, ...@@ -94,12 +94,20 @@ func (s *ListService) Create(userID uint, req CreateListRequest) (*ListResponse,
func (s *ListService) FindByID(userID uint, id string) (*ListResponse, error) { func (s *ListService) FindByID(userID uint, id string) (*ListResponse, error) {
var list models.List var list models.List
if err := s.db.Where("id = ? AND user_id = ? AND deleted_at IS NULL", id, userID).First(&list).Error; err != nil { if err := s.db.Where("id = ? AND deleted_at IS NULL", id).First(&list).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("list not found") return nil, fmt.Errorf("list not found")
} }
return nil, err return nil, err
} }
ok, err := s.canAccessList(userID, id)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("list not found")
}
return toListResponse(list), nil return toListResponse(list), nil
} }
...@@ -167,6 +175,79 @@ func (s *ListService) FindAll(userID uint, query QueryListsRequest) (*PaginatedL ...@@ -167,6 +175,79 @@ func (s *ListService) FindAll(userID uint, query QueryListsRequest) (*PaginatedL
}, nil }, nil
} }
func (s *ListService) FindByPlayerID(userID uint, playerID string, query QueryListsRequest) (*PaginatedListsResponse, error) {
q := s.db.
Model(&models.List{}).
Joins("LEFT JOIN list_shares ON list_shares.list_id = lists.id AND list_shares.user_id = ?", userID).
Where("lists.deleted_at IS NULL").
Where("lists.user_id = ? OR list_shares.user_id = ?", userID, userID).
Where(
"jsonb_path_exists(lists.players_by_position, ?::jsonpath, jsonb_build_object('pid', to_jsonb(?::text)))",
"$.*.playersIds[*].playerId ? (@ == $pid)",
playerID,
)
if query.Type != "" {
q = q.Where("lists.type = ?", query.Type)
}
if query.Name != "" {
q = q.Where("LOWER(lists.name) LIKE ?", "%"+strings.ToLower(query.Name)+"%")
}
if query.Season != "" {
q = q.Where("lists.season = ?", query.Season)
}
if query.IsActive != nil {
q = q.Where("lists.is_active = ?", *query.IsActive)
}
var total int64
q.Count(&total)
sortBy := "name"
allowedSorts := map[string]string{
"name": "lists.name",
"type": "lists.type",
"season": "lists.season",
"createdAt": "lists.created_at",
"updatedAt": "lists.updated_at",
}
if col, ok := allowedSorts[query.SortBy]; ok {
sortBy = col
}
sortOrder := "ASC"
if strings.ToLower(query.SortOrder) == "desc" {
sortOrder = "DESC"
}
limit := query.Limit
if limit <= 0 || limit > 1000 {
limit = 100
}
offset := query.Offset
if offset < 0 {
offset = 0
}
var lists []models.List
if err := q.Order(sortBy + " " + sortOrder).Limit(limit).Offset(offset).Find(&lists).Error; err != nil {
return nil, err
}
data := make([]ListResponse, len(lists))
for i, l := range lists {
data[i] = *toListResponse(l)
}
return &PaginatedListsResponse{
Data: data,
Total: total,
Limit: limit,
Offset: offset,
HasMore: int64(offset+len(lists)) < total,
}, nil
}
func (s *ListService) FindSharedWithUser(userID uint, query QueryListsRequest) (*PaginatedListsResponse, error) { func (s *ListService) FindSharedWithUser(userID uint, query QueryListsRequest) (*PaginatedListsResponse, error) {
q := s.db. q := s.db.
Model(&models.List{}). Model(&models.List{}).
...@@ -287,6 +368,58 @@ func (s *ListService) Delete(userID uint, id string) error { ...@@ -287,6 +368,58 @@ func (s *ListService) Delete(userID uint, id string) error {
return s.db.Model(&list).Update("deleted_at", now).Error return s.db.Model(&list).Update("deleted_at", now).Error
} }
func (s *ListService) Share(actor models.User, listID string, shareWithUserID uint) error {
var list models.List
if err := s.db.Where("id = ? AND deleted_at IS NULL", listID).First(&list).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("list not found")
}
return err
}
if list.UserID != actor.ID {
return fmt.Errorf("forbidden")
}
share := models.ListShare{ListID: listID, UserID: shareWithUserID}
if err := s.db.Create(&share).Error; err != nil {
return err
}
return nil
}
func (s *ListService) Unshare(actor models.User, listID string, shareWithUserID uint) error {
var list models.List
if err := s.db.Where("id = ? AND deleted_at IS NULL", listID).First(&list).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("list not found")
}
return err
}
if list.UserID != actor.ID {
return fmt.Errorf("forbidden")
}
res := s.db.Where("list_id = ? AND user_id = ?", listID, shareWithUserID).Delete(&models.ListShare{})
if res.Error != nil {
return res.Error
}
return nil
}
func (s *ListService) canAccessList(userID uint, listID string) (bool, error) {
var count int64
err := s.db.
Model(&models.List{}).
Joins("LEFT JOIN list_shares ON list_shares.list_id = lists.id AND list_shares.user_id = ?", userID).
Where("lists.id = ? AND lists.deleted_at IS NULL", listID).
Where("lists.user_id = ? OR list_shares.user_id = ?", userID, userID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
func toListResponse(l models.List) *ListResponse { func toListResponse(l models.List) *ListResponse {
isActive := l.IsActive != nil && *l.IsActive isActive := l.IsActive != nil && *l.IsActive
var players interface{} var players interface{}
......
package services
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"ScoutSystemElite/internal/models"
)
type TeamService struct {
db *gorm.DB
}
func NewTeamService(db *gorm.DB) *TeamService {
return &TeamService{db: db}
}
type TeamResponse struct {
ID string `json:"id"`
Name string `json:"name"`
OfficialName *string `json:"officialName"`
ShortName *string `json:"shortName"`
Description *string `json:"description"`
Type string `json:"type"`
Category string `json:"category"`
Gender *string `json:"gender"`
AreaWyID *int `json:"areaWyId"`
City *string `json:"city"`
ImageDataURL *string `json:"imageDataUrl"`
IsActive bool `json:"isActive"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt *time.Time `json:"deletedAt"`
}
type QueryTeamsRequest struct {
Name string `form:"name"`
Type string `form:"type"`
Category string `form:"category"`
Gender string `form:"gender"`
AreaWyID *int `form:"areaWyId"`
IsActive *bool `form:"isActive"`
Limit int `form:"limit"`
Offset int `form:"offset"`
SortBy string `form:"sortBy"`
SortOrder string `form:"sortOrder"`
}
type CreateTeamRequest struct {
Name string `json:"name" binding:"required"`
OfficialName *string `json:"officialName"`
ShortName *string `json:"shortName"`
Description *string `json:"description"`
Type *string `json:"type"`
Category *string `json:"category"`
Gender *string `json:"gender"`
AreaWyID *int `json:"areaWyId"`
City *string `json:"city"`
ImageDataUrl *string `json:"imageDataUrl"`
ImageDataURL *string `json:"imageDataURL"`
}
type UpdateTeamRequest struct {
Name *string `json:"name"`
OfficialName *string `json:"officialName"`
ShortName *string `json:"shortName"`
Description *string `json:"description"`
Type *string `json:"type"`
Category *string `json:"category"`
Gender *string `json:"gender"`
AreaWyID *int `json:"areaWyId"`
City *string `json:"city"`
ImageDataUrl *string `json:"imageDataUrl"`
ImageDataURL *string `json:"imageDataURL"`
IsActive *bool `json:"isActive"`
}
type PaginatedTeamsResponse struct {
Data []TeamResponse `json:"data"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
HasMore bool `json:"hasMore"`
}
func (s *TeamService) Create(req CreateTeamRequest) (*TeamResponse, error) {
team := models.Team{
ID: generateTeamID(),
Name: req.Name,
OfficialName: req.OfficialName,
ShortName: req.ShortName,
Description: req.Description,
Type: derefStringTeam(req.Type, "club"),
Category: derefStringTeam(req.Category, "default"),
Gender: req.Gender,
AreaWyID: req.AreaWyID,
City: req.City,
ImageDataURL: coalesceStringTeam(req.ImageDataURL, req.ImageDataUrl),
IsActive: true,
}
if err := s.db.Create(&team).Error; err != nil {
return nil, err
}
return toTeamResponse(team), nil
}
func (s *TeamService) FindAll(query QueryTeamsRequest) (*PaginatedTeamsResponse, error) {
q := s.db.Model(&models.Team{}).Where("deleted_at IS NULL")
if query.Name != "" {
nameLower := "%" + strings.ToLower(query.Name) + "%"
q = q.Where(
strings.Join([]string{
"LOWER(name) LIKE ?",
"LOWER(official_name) LIKE ?",
"LOWER(short_name) LIKE ?",
}, " OR "),
nameLower, nameLower, nameLower,
)
}
if query.Type != "" {
q = q.Where("type = ?", query.Type)
}
if query.Category != "" {
q = q.Where("category = ?", query.Category)
}
if query.Gender != "" {
q = q.Where("gender = ?", query.Gender)
}
if query.AreaWyID != nil {
q = q.Where("area_wy_id = ?", *query.AreaWyID)
}
if query.IsActive != nil {
q = q.Where("is_active = ?", *query.IsActive)
}
var total int64
q.Count(&total)
sortBy := "name"
allowedSorts := map[string]string{
"name": "name",
"type": "type",
"category": "category",
"areaWyId": "area_wy_id",
"createdAt": "created_at",
"updatedAt": "updated_at",
}
if col, ok := allowedSorts[query.SortBy]; ok {
sortBy = col
}
sortOrder := "ASC"
if strings.ToLower(query.SortOrder) == "desc" {
sortOrder = "DESC"
}
limit := query.Limit
if limit <= 0 || limit > 1000 {
limit = 100
}
offset := query.Offset
if offset < 0 {
offset = 0
}
var teams []models.Team
if err := q.Order(sortBy + " " + sortOrder).Limit(limit).Offset(offset).Find(&teams).Error; err != nil {
return nil, err
}
data := make([]TeamResponse, len(teams))
for i, t := range teams {
data[i] = *toTeamResponse(t)
}
return &PaginatedTeamsResponse{
Data: data,
Total: total,
Limit: limit,
Offset: offset,
HasMore: int64(offset+len(teams)) < total,
}, nil
}
func (s *TeamService) FindByID(id string) (*TeamResponse, error) {
var team models.Team
if err := s.db.Where("id = ? AND deleted_at IS NULL", id).First(&team).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return toTeamResponse(team), nil
}
func (s *TeamService) UpdateByID(id string, req UpdateTeamRequest) (*TeamResponse, error) {
var team models.Team
if err := s.db.Where("id = ? AND deleted_at IS NULL", id).First(&team).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
updates := make(map[string]interface{})
if req.Name != nil {
updates["name"] = *req.Name
}
if req.OfficialName != nil {
updates["official_name"] = *req.OfficialName
}
if req.ShortName != nil {
updates["short_name"] = *req.ShortName
}
if req.Description != nil {
updates["description"] = *req.Description
}
if req.Type != nil {
updates["type"] = *req.Type
}
if req.Category != nil {
updates["category"] = *req.Category
}
if req.Gender != nil {
updates["gender"] = *req.Gender
}
if req.AreaWyID != nil {
updates["area_wy_id"] = *req.AreaWyID
}
if req.City != nil {
updates["city"] = *req.City
}
if img := coalesceStringTeam(req.ImageDataURL, req.ImageDataUrl); img != nil {
updates["image_data_url"] = *img
}
if req.IsActive != nil {
updates["is_active"] = *req.IsActive
}
if len(updates) > 0 {
updates["updated_at"] = time.Now()
if err := s.db.Model(&team).Updates(updates).Error; err != nil {
return nil, err
}
s.db.First(&team, "id = ?", id)
}
return toTeamResponse(team), nil
}
func (s *TeamService) DeleteByID(id string) error {
var team models.Team
if err := s.db.Where("id = ? AND deleted_at IS NULL", id).First(&team).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("team not found")
}
return err
}
now := time.Now()
return s.db.Model(&team).Update("deleted_at", now).Error
}
func toTeamResponse(t models.Team) *TeamResponse {
return &TeamResponse{
ID: t.ID,
Name: t.Name,
OfficialName: t.OfficialName,
ShortName: t.ShortName,
Description: t.Description,
Type: t.Type,
Category: t.Category,
Gender: t.Gender,
AreaWyID: t.AreaWyID,
City: t.City,
ImageDataURL: t.ImageDataURL,
IsActive: t.IsActive,
CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt,
DeletedAt: t.DeletedAt,
}
}
func generateTeamID() string {
b := make([]byte, 8)
rand.Read(b)
return hex.EncodeToString(b)
}
func derefStringTeam(s *string, def string) string {
if s == nil {
return def
}
return *s
}
func coalesceStringTeam(a *string, b *string) *string {
if a != nil && *a != "" {
return a
}
if b != nil && *b != "" {
return b
}
return nil
}
ALTER TABLE players
ADD COLUMN IF NOT EXISTS created_by_user_id bigint,
ADD COLUMN IF NOT EXISTS updated_by_user_id bigint;
CREATE INDEX IF NOT EXISTS idx_players_created_by_user_id ON players(created_by_user_id);
CREATE INDEX IF NOT EXISTS idx_players_updated_by_user_id ON players(updated_by_user_id);
-- Optional foreign keys:
ALTER TABLE players
ADD CONSTRAINT fk_players_created_by_user FOREIGN KEY (created_by_user_id) REFERENCES users(id),
ADD CONSTRAINT fk_players_updated_by_user FOREIGN KEY (updated_by_user_id) REFERENCES users(id);
CREATE TABLE IF NOT EXISTS teams (
id varchar(16) PRIMARY KEY,
name text NOT NULL,
official_name text,
short_name text,
description text,
type text NOT NULL DEFAULT 'club',
category text NOT NULL DEFAULT 'default',
gender text,
area_wy_id integer,
city text,
image_data_url text,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_teams_name ON teams(name);
CREATE INDEX IF NOT EXISTS idx_teams_area_wy_id ON teams(area_wy_id);
CREATE INDEX IF NOT EXISTS idx_teams_is_active ON teams(is_active);
CREATE INDEX IF NOT EXISTS idx_teams_deleted_at ON teams(deleted_at);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment