diff --git a/cmd/seeder/main.go b/cmd/seeder/main.go index b8e76a1..c79a5a1 100644 --- a/cmd/seeder/main.go +++ b/cmd/seeder/main.go @@ -22,6 +22,7 @@ func main() { seeder.WithContext(ctx), seeder.WithSeeder(seeder.NewRoleSeeder()), seeder.WithSeeder(seeder.NewUserSeeder()), + seeder.WithSeeder(seeder.NewRaritySeeder()), ) go func() { diff --git a/docs/swagger/docs.go b/docs/swagger/docs.go index 6eb7da2..6cbce2c 100644 --- a/docs/swagger/docs.go +++ b/docs/swagger/docs.go @@ -1019,6 +1019,96 @@ const docTemplate = `{ } } }, + "/profiles/{id}/pfp": { + "get": { + "description": "Returns an uploaded profile picture", + "consumes": [ + "application/json" + ], + "produces": [ + "multipart/form-data" + ], + "tags": [ + "profiles" + ], + "parameters": [ + { + "type": "integer", + "description": "id of desired profile", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + } + } + }, + "post": { + "description": "Uploads a profile picture", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "parameters": [ + { + "type": "integer", + "description": "id of desired profile", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "returns newly created profile picture's URI", + "schema": { + "type": "string" + } + }, + "400": { + "description": "no file sent", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + }, + "415": { + "description": "invalid media type", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + } + } + } + }, "/roles": { "get": { "description": "Reads all roles", @@ -1884,6 +1974,9 @@ const docTemplate = `{ }, "last_login": { "type": "string" + }, + "role": { + "type": "string" } } }, diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index daac81d..b0f6c33 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -1012,6 +1012,96 @@ } } }, + "/profiles/{id}/pfp": { + "get": { + "description": "Returns an uploaded profile picture", + "consumes": [ + "application/json" + ], + "produces": [ + "multipart/form-data" + ], + "tags": [ + "profiles" + ], + "parameters": [ + { + "type": "integer", + "description": "id of desired profile", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + } + } + }, + "post": { + "description": "Uploads a profile picture", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "profiles" + ], + "parameters": [ + { + "type": "integer", + "description": "id of desired profile", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "returns newly created profile picture's URI", + "schema": { + "type": "string" + } + }, + "400": { + "description": "no file sent", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + }, + "401": { + "description": "unauthorized", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + }, + "415": { + "description": "invalid media type", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + }, + "500": { + "description": "internal server error", + "schema": { + "$ref": "#/definitions/dtos.ErrResp" + } + } + } + } + }, "/roles": { "get": { "description": "Reads all roles", @@ -1877,6 +1967,9 @@ }, "last_login": { "type": "string" + }, + "role": { + "type": "string" } } }, diff --git a/docs/swagger/swagger.yaml b/docs/swagger/swagger.yaml index 9375c68..ff1b533 100644 --- a/docs/swagger/swagger.yaml +++ b/docs/swagger/swagger.yaml @@ -186,6 +186,8 @@ definitions: type: boolean last_login: type: string + role: + type: string type: object dtos.UserUpdateDTO: properties: @@ -864,6 +866,65 @@ paths: $ref: '#/definitions/dtos.ErrResp' tags: - profiles + /profiles/{id}/pfp: + get: + consumes: + - application/json + description: Returns an uploaded profile picture + parameters: + - description: id of desired profile + in: path + name: id + required: true + type: integer + produces: + - multipart/form-data + responses: + "404": + description: record not found + schema: + $ref: '#/definitions/dtos.ErrResp' + "500": + description: internal server error + schema: + $ref: '#/definitions/dtos.ErrResp' + tags: + - profiles + post: + consumes: + - multipart/form-data + description: Uploads a profile picture + parameters: + - description: id of desired profile + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "201": + description: returns newly created profile picture's URI + schema: + type: string + "400": + description: no file sent + schema: + $ref: '#/definitions/dtos.ErrResp' + "401": + description: unauthorized + schema: + $ref: '#/definitions/dtos.ErrResp' + "415": + description: invalid media type + schema: + $ref: '#/definitions/dtos.ErrResp' + "500": + description: internal server error + schema: + $ref: '#/definitions/dtos.ErrResp' + tags: + - profiles /roles: get: consumes: diff --git a/internal/DTOs/ItemDTOs.go b/internal/DTOs/ItemDTOs.go new file mode 100644 index 0000000..113054c --- /dev/null +++ b/internal/DTOs/ItemDTOs.go @@ -0,0 +1,95 @@ +package dtos + +import ( + "context" + "smaash-web/internal/models" + "smaash-web/internal/utils" + + "gorm.io/gorm" +) + +type ItemReadDTO struct { + ID uint `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Price uint `json:"price"` + Rarity string `json:"rarity"` + Categories []string `json:"categories"` +} + +type ItemCreateDTO struct { + Name string `json:"name" binding:"required,max=20"` + Description string `json:"description" binding:"required,max=50"` + Price uint `json:"price" binding:"required,gte=0"` + Rarity string `json:"rarity" binding:"required,max=9"` + Categories []string `json:"categories" binding:"required"` +} + +type ItemUpdateDTO struct { + ID uint `json:"id" binding:"required"` + Name string `json:"name" binding:"required,max=20"` + Description string `json:"description" binding:"required,max=50"` + Price uint `json:"price" binding:"required,gte=0"` + Rarity string `json:"rarity" binding:"required.max=9"` + Categories []string `json:"categories" binding:"required"` +} + +func ItemToDTO(item models.Item) ItemReadDTO { + return ItemReadDTO{ + ID: item.ID, + Name: item.Name, + Description: item.Description, + Price: item.Price, + Rarity: item.Rarity.Name, + Categories: utils.Map(item.Categories, func(c *models.Category) string { return c.Name }), + } +} + +func CreateDTOToItem( + dto ItemCreateDTO, + rarityExtracor func(c context.Context, rarityName string) (models.Rarity, error), + categoryExtractor func(c context.Context, categoryName string) (*models.Category, error), +) (*models.Item, error) { + rarity, err := rarityExtracor(context.Background(), dto.Rarity) + if err != nil { + return nil, err + } + return &models.Item{ + Name: dto.Name, + Description: dto.Description, + Price: dto.Price, + RarityID: rarity.ID, + Categories: utils.Map(dto.Categories, func(name string) *models.Category { + category, err := categoryExtractor(context.Background(), name) + if err != nil { + panic("failed convert categories from request dto to model") + } + return category + }), + }, nil +} + +func UpdateDTOToItem( + dto ItemUpdateDTO, + rarityExtractor func(c context.Context, rarityName string) (models.Rarity, error), + categoryExtractor func(c context.Context, categoryName string) (*models.Category, error), +) (*models.Item, error) { + rarity, err := rarityExtractor(context.Background(), dto.Rarity) + if err != nil { + return nil, err + } + return &models.Item{ + Model: gorm.Model{ID: dto.ID}, + Name: dto.Name, + Description: dto.Description, + Price: dto.Price, + RarityID: rarity.ID, + Categories: utils.Map(dto.Categories, func(name string) *models.Category { + category, err := categoryExtractor(context.Background(), name) + if err != nil { + panic("failed convert categories from request dto to model") + } + return category + }), + }, nil +} diff --git a/internal/controllers/Items_controller.go b/internal/controllers/Items_controller.go new file mode 100644 index 0000000..3e6154d --- /dev/null +++ b/internal/controllers/Items_controller.go @@ -0,0 +1,151 @@ +package controllers + +import ( + "errors" + "net/http" + dtos "smaash-web/internal/DTOs" + "smaash-web/internal/middlewares" + "smaash-web/internal/models" + "smaash-web/internal/repository" + "smaash-web/internal/utils" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type ItemsController struct { + itemsBaseRepo repository.BaseRepository[models.Item] + rarityRepo repository.RarityRepository + categoryRepo repository.CategoryRepository +} + +func NewItemsController( + itemsBaseRepo repository.BaseRepository[models.Item], + rarityRepo repository.RarityRepository, + categoryRepo repository.CategoryRepository, +) *ItemsController { + return &ItemsController{ + itemsBaseRepo: itemsBaseRepo, + rarityRepo: rarityRepo, + categoryRepo: categoryRepo, + } +} + +func (ic ItemsController) Create(c *gin.Context) { + path := c.Request.URL.Path + var body dtos.ItemCreateDTO + + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, dtos.NewErrResp(err.Error(), path)) + return + } + + newItem, err := dtos.CreateDTOToItem(body, ic.rarityRepo.ReadByName, ic.categoryRepo.ReadByName) + if err != nil { + c.JSON(http.StatusBadRequest, dtos.NewErrResp("Specified rarity or category doesn't exist", path)) + return + } + + if err := ic.itemsBaseRepo.Create(c.Request.Context(), newItem); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + c.JSON(http.StatusConflict, dtos.NewErrResp("Item with given name already exists", path)) + return + } + c.JSON(http.StatusInternalServerError, dtos.NewErrResp(err.Error(), path)) + return + } + + withIncludes, _ := ic.itemsBaseRepo.ReadByID(c.Request.Context(), newItem.ID, "Rarity", "Categories") + + c.JSON(http.StatusCreated, dtos.ItemToDTO(withIncludes)) +} + +func (ic ItemsController) ReadAll(c *gin.Context) { + items, err := ic.itemsBaseRepo.ReadAll(c.Request.Context(), "Rarity", "Categories") + if err != nil { + c.JSON(http.StatusInternalServerError, dtos.NewErrResp(err.Error(), c.Request.URL.Path)) + return + } + + c.JSON(http.StatusOK, utils.Map(items, dtos.ItemToDTO)) +} + +func (ic ItemsController) ReadByID(c *gin.Context) { + path := c.Request.URL.Path + id, _ := c.Get("id") + + item, err := ic.itemsBaseRepo.ReadByID(c.Request.Context(), id.(uint), "Rarity", "Categories") + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, dtos.NewErrResp("Item with given id not found", path)) + return + } + c.JSON(http.StatusInternalServerError, dtos.NewErrResp(err.Error(), path)) + return + } + + c.JSON(http.StatusOK, dtos.ItemToDTO(item)) +} + +func (ic ItemsController) Update(c *gin.Context) { + path := c.Request.URL.Path + id, _ := c.Get("id") + + var body dtos.ItemUpdateDTO + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, dtos.NewErrResp(err.Error(), path)) + return + } + + if id.(uint) != body.ID { + c.JSON(http.StatusBadRequest, dtos.NewErrResp("Id from request body doesn't match id from url", path)) + return + } + + updatedItem, err := dtos.UpdateDTOToItem(body, ic.rarityRepo.ReadByName, ic.categoryRepo.ReadByName) + if err != nil { + c.JSON(http.StatusBadRequest, dtos.NewErrResp("Specified rarity or category doesn't exist", path)) + return + } + + if err := ic.itemsBaseRepo.Update(c.Request.Context(), *updatedItem); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, dtos.NewErrResp("Item with provided id not found", path)) + return + } + if errors.Is(err, gorm.ErrDuplicatedKey) { + c.JSON(http.StatusConflict, dtos.NewErrResp("Item with given name already exists", path)) + return + } + c.JSON(http.StatusInternalServerError, dtos.NewErrResp(err.Error(), path)) + return + } + + c.Status(http.StatusNoContent) +} + +func (ic ItemsController) Delete(c *gin.Context) { + path := c.Request.URL.Path + id, _ := c.Get("id") + + if err := ic.itemsBaseRepo.Delete(c.Request.Context(), id.(uint)); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, dtos.NewErrResp("Item with given id not found", path)) + return + } + c.JSON(http.StatusInternalServerError, dtos.NewErrResp(err.Error(), path)) + return + } + + c.Status(http.StatusNoContent) +} + +func (ic ItemsController) MountRoutes(apiGroup *gin.RouterGroup) { + items := apiGroup.Group("/items") + items.Use(middlewares.Authorize) + items.POST("", ic.Create) + items.GET("", ic.ReadAll) + items.GET("/:id", middlewares.ValidateUrl, ic.ReadByID) + items.PUT("/:id", middlewares.ValidateUrl, ic.Update) + items.DELETE("/:id", middlewares.ValidateUrl, ic.Delete) +} diff --git a/internal/controllers/player_profiles_controller.go b/internal/controllers/player_profiles_controller.go index 4680044..7c0406c 100644 --- a/internal/controllers/player_profiles_controller.go +++ b/internal/controllers/player_profiles_controller.go @@ -167,6 +167,17 @@ func (pc PlayerProfileController) Delete(c *gin.Context) { c.Status(http.StatusNoContent) } +// @description Uploads a profile picture +// @tags profiles +// @accept mpfd +// @produce json +// @param id path int true "id of desired profile" +// @success 201 {object} string "returns newly created profile picture's URI" +// @failure 400 {object} dtos.ErrResp "no file sent" +// @failure 401 {object} dtos.ErrResp "unauthorized" +// @failure 415 {object} dtos.ErrResp "invalid media type" +// @failure 500 {object} dtos.ErrResp "internal server error" +// @router /profiles/{id}/pfp [post] func (pc PlayerProfileController) UploadPFP(c *gin.Context) { path := c.Request.URL.Path id, _ := c.Get("id") @@ -195,6 +206,14 @@ func (pc PlayerProfileController) UploadPFP(c *gin.Context) { c.String(http.StatusCreated, "uri") } +// @description Returns an uploaded profile picture +// @tags profiles +// @accept json +// @produce mpfd +// @param id path int true "id of desired profile" +// @failure 404 {object} dtos.ErrResp "record not found" +// @failure 500 {object} dtos.ErrResp "internal server error" +// @router /profiles/{id}/pfp [get] func (pc PlayerProfileController) GetPFP(c *gin.Context) { path := c.Request.URL.Path id, _ := c.Get("id") diff --git a/internal/initializer/initialize.go b/internal/initializer/initialize.go index da48712..7dbe1bf 100644 --- a/internal/initializer/initialize.go +++ b/internal/initializer/initialize.go @@ -14,6 +14,8 @@ func Initialize() *server.Server { conn := database.NewGormDBConn() userRepo := repository.NewUserRepositoryActions(conn) profilesBaseRepo := repository.NewBaseRepositoryActions[models.PlayerProfile](conn) + rarityRepo := repository.NewRarityRepositoryActions(conn) + categoriesRepo := repository.NewCategoryRepositoryActions(conn) authService := services.NewAuthenticationService(userRepo) @@ -25,5 +27,6 @@ func Initialize() *server.Server { AddController(controllers.NewLevelsController(repository.NewBaseRepositoryActions[models.Level](conn))). AddController(controllers.NewPlayerProfileController(profilesBaseRepo)). AddController(controllers.NewRolesController(repository.NewBaseRepositoryActions[models.Role](conn))). - AddController(controllers.NewCategoriesController(repository.NewBaseRepositoryActions[models.Category](conn))) + AddController(controllers.NewCategoriesController(categoriesRepo)). + AddController(controllers.NewItemsController(repository.NewBaseRepositoryActions[models.Item](conn), rarityRepo, categoriesRepo)) } diff --git a/internal/models/Item.go b/internal/models/Item.go index 2139710..fcfe28f 100644 --- a/internal/models/Item.go +++ b/internal/models/Item.go @@ -7,6 +7,7 @@ type Item struct { Name string `gorm:"unique;not null;type:varchar(20)"` Description string `gorm:"not null;type:varchar(50)"` RarityID uint `gorm:"not null"` + Rarity Rarity Price uint `gorm:"not null"` ImgUri string `gorm:"not null"` Purchases []Purchase diff --git a/internal/repository/category_repository_actions.go b/internal/repository/category_repository_actions.go new file mode 100644 index 0000000..6884a5c --- /dev/null +++ b/internal/repository/category_repository_actions.go @@ -0,0 +1,30 @@ +package repository + +import ( + "context" + "smaash-web/internal/models" + + "gorm.io/gorm" +) + +type CategoryRepository interface { + BaseRepository[models.Category] + ReadByName(context.Context, string) (*models.Category, error) +} + +type CategoryRepositoryActions struct { + BaseRepository[models.Category] + conn *gorm.DB +} + +func NewCategoryRepositoryActions(conn *gorm.DB) CategoryRepository { + return &CategoryRepositoryActions{ + BaseRepository: NewBaseRepositoryActions[models.Category](conn), + conn: conn, + } +} + +func (cra CategoryRepositoryActions) ReadByName(c context.Context, name string) (*models.Category, error) { + result, err := gorm.G[models.Category](cra.conn).Where("name = ?", name).First(c) + return &result, err +} diff --git a/internal/repository/rarity_repository_actions.go b/internal/repository/rarity_repository_actions.go new file mode 100644 index 0000000..8923efa --- /dev/null +++ b/internal/repository/rarity_repository_actions.go @@ -0,0 +1,29 @@ +package repository + +import ( + "context" + "smaash-web/internal/models" + + "gorm.io/gorm" +) + +type RarityRepository interface { + BaseRepository[models.Rarity] + ReadByName(context.Context, string) (models.Rarity, error) +} + +type RarityRepositoryActions struct { + BaseRepository[models.Rarity] + conn *gorm.DB +} + +func NewRarityRepositoryActions(conn *gorm.DB) RarityRepository { + return RarityRepositoryActions{ + conn: conn, + BaseRepository: NewBaseRepositoryActions[models.Rarity](conn), + } +} + +func (rra RarityRepositoryActions) ReadByName(c context.Context, name string) (models.Rarity, error) { + return gorm.G[models.Rarity](rra.conn).Where("name = ?", name).First(c) +} diff --git a/internal/seeder/rarity_seeder.go b/internal/seeder/rarity_seeder.go new file mode 100644 index 0000000..b19fdd8 --- /dev/null +++ b/internal/seeder/rarity_seeder.go @@ -0,0 +1,54 @@ +package seeder + +import ( + "context" + "encoding/json" + "errors" + "log" + "os" + "smaash-web/internal/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type RaritySeeder struct{} + +func NewRaritySeeder() *RaritySeeder { + return &RaritySeeder{} +} + +type RarityDataFormat struct { + Name string +} + +func (rs RaritySeeder) Seed(c context.Context, data_root_path string, db_url string, errStream chan error, logger logger.Interface) { + log.Println("Starting rarities seeder") + db, err := gorm.Open(sqlite.Open(db_url), &gorm.Config{TranslateError: true, Logger: logger}) + if err != nil { + errStream <- err + } + + raw, err := os.ReadFile(data_root_path + "/rarities.json") + if err != nil { + errStream <- err + } + + var target []RarityDataFormat + if err = json.Unmarshal(raw, &target); err != nil { + errStream <- err + } + + for _, val := range target { + if err := gorm.G[models.Rarity](db).Create(c, &models.Rarity{Name: val.Name}); err != nil { + if !errors.Is(err, gorm.ErrDuplicatedKey) { + errStream <- err + } else { + log.Println("Skipped creating role with name: ", val.Name) + } + } else { + log.Println("Created role with name: ", val.Name) + } + } +}