diff --git a/cmd/api/server.go b/cmd/api/server.go index 2f12625..e216c4b 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -165,6 +165,8 @@ func initializeRoutes(engine *gin.Engine) { engine.POST("/v2/map/:id/mods", middleware.RequireAuth, handlers.CreateHandler(handlers.SubmitMapMod)) engine.POST("/v2/map/:id/mods/:mod_id/status", middleware.RequireAuth, handlers.CreateHandler(handlers.UpdateMapModStatus)) engine.POST("/v2/map/:id/mods/:mod_id/comment", middleware.RequireAuth, handlers.CreateHandler(handlers.SubmitMapModComment)) + engine.POST("/v2/map/mod/:id/edit", middleware.RequireAuth, handlers.CreateHandler(handlers.EditMapMod)) + engine.POST("/v2/map/mod/comment/:id/edit", middleware.RequireAuth, handlers.CreateHandler(handlers.EditMapModComment)) // Mapsets engine.GET("/v2/mapset/search", handlers.CreateHandler(handlers.GetMapsetsSearch)) diff --git a/db/map_mods.go b/db/map_mods.go index ee3c29e..e6d9979 100644 --- a/db/map_mods.go +++ b/db/map_mods.go @@ -89,6 +89,21 @@ func (mod *MapMod) Insert() error { return nil } +// Edit Updates the editable content of a pending map mod. +func (mod *MapMod) Edit(comment string, mapTimestamp *string) error { + mod.Comment = comment + mod.MapTimestamp = mapTimestamp + + result := SQL.Model(&MapMod{}). + Where("id = ?", mod.Id). + Updates(map[string]interface{}{ + "comment": mod.Comment, + "map_timestamp": mod.MapTimestamp, + }) + + return result.Error +} + // UpdateStatus Updates the status of a map mod func (mod *MapMod) UpdateStatus(status MapModStatus) error { mod.Status = status diff --git a/db/map_mods_comments.go b/db/map_mods_comments.go index 6ccfeaf..0909d97 100644 --- a/db/map_mods_comments.go +++ b/db/map_mods_comments.go @@ -35,3 +35,29 @@ func (comment *MapModComment) Insert() error { return nil } + +// GetMapModCommentById Gets a map mod comment by its id. +func GetMapModCommentById(id int) (*MapModComment, error) { + var comment *MapModComment + + result := SQL. + Where("id = ?", id). + First(&comment) + + if result.Error != nil { + return nil, result.Error + } + + return comment, nil +} + +// Edit Updates the content of a map mod comment. +func (comment *MapModComment) Edit(content string) error { + comment.Comment = content + + result := SQL.Model(&MapModComment{}). + Where("id = ?", comment.Id). + Update("comment", comment.Comment) + + return result.Error +} diff --git a/handlers/map_mods.go b/handlers/map_mods.go index 3d67bdb..48f7893 100644 --- a/handlers/map_mods.go +++ b/handlers/map_mods.go @@ -242,6 +242,154 @@ func SubmitMapModComment(c *gin.Context) *APIError { return nil } +// EditMapMod Edits the content of a pending map mod. +// Endpoint: POST /v2/map/mod/:id/edit +func EditMapMod(c *gin.Context) *APIError { + modId, err := strconv.Atoi(c.Param("id")) + + if err != nil { + return APIErrorBadRequest("Invalid mod id") + } + + user := getAuthedUser(c) + + if user == nil { + return nil + } + + body := struct { + MapTimestamp *string `form:"map_timestamp" json:"map_timestamp"` + Comment string `form:"comment" json:"comment" binding:"required"` + }{} + + if err := c.ShouldBind(&body); err != nil { + return APIErrorBadRequest("Invalid request body") + } + + if apiErr := validateEditableMapModComment(body.Comment); apiErr != nil { + return apiErr + } + + var apiErr *APIError + body.MapTimestamp, apiErr = normalizeEditableMapModTimestamp(body.MapTimestamp) + + if apiErr != nil { + return apiErr + } + + mod, err := db.GetModById(modId) + + if err != nil && err != gorm.ErrRecordNotFound { + return APIErrorServerError("Error retrieving map mod from database", err) + } + + if mod == nil { + return APIErrorNotFound("Mod") + } + + if mod.AuthorId != user.Id { + return APIErrorForbidden("You are not the author of this mod.") + } + + if mod.Status != db.ModStatusPending { + return APIErrorForbidden("You can only edit pending mods.") + } + + if err := mod.Edit(body.Comment, body.MapTimestamp); err != nil { + return APIErrorServerError("Error updating map mod in the database", err) + } + + c.JSON(http.StatusOK, gin.H{"message": "Your mod has been successfully edited."}) + return nil +} + +// EditMapModComment Edits the content of a comment on a pending map mod. +// Endpoint: POST /v2/map/mod/comment/:id/edit +func EditMapModComment(c *gin.Context) *APIError { + commentId, err := strconv.Atoi(c.Param("id")) + + if err != nil { + return APIErrorBadRequest("Invalid comment id") + } + + user := getAuthedUser(c) + + if user == nil { + return nil + } + + body := struct { + Comment string `form:"comment" json:"comment" binding:"required"` + }{} + + if err := c.ShouldBind(&body); err != nil { + return APIErrorBadRequest("Invalid request body") + } + + if apiErr := validateEditableMapModComment(body.Comment); apiErr != nil { + return apiErr + } + + comment, err := db.GetMapModCommentById(commentId) + + if err != nil && err != gorm.ErrRecordNotFound { + return APIErrorServerError("Error retrieving map mod comment from database", err) + } + + if comment == nil { + return APIErrorNotFound("Comment") + } + + if comment.AuthorId != user.Id { + return APIErrorForbidden("You are not the author of this comment.") + } + + mod, err := db.GetModById(comment.MapModId) + + if err != nil && err != gorm.ErrRecordNotFound { + return APIErrorServerError("Error retrieving map mod from database", err) + } + + if mod == nil { + return APIErrorNotFound("Mod") + } + + if mod.Status != db.ModStatusPending { + return APIErrorForbidden("You can only edit comments on pending mods.") + } + + if err := comment.Edit(body.Comment); err != nil { + return APIErrorServerError("Error updating map mod comment in the database", err) + } + + c.JSON(http.StatusOK, gin.H{"message": "Your comment has been successfully edited."}) + return nil +} + +func validateEditableMapModComment(comment string) *APIError { + if len(comment) == 0 || len(comment) > 5000 { + return APIErrorBadRequest("Your comment must be between 1 and 5,000 characters") + } + + return nil +} + +func normalizeEditableMapModTimestamp(timestamp *string) (*string, *APIError) { + if timestamp == nil || len(*timestamp) == 0 { + return nil, nil + } + + if len(*timestamp) > 5000 { + return nil, APIErrorBadRequest("The map timestamp can't be greater than 5,000 characters") + } + + if !isMapTimestampValid(*timestamp) { + return nil, APIErrorBadRequest("You have provided an invalid map timestamp") + } + + return timestamp, nil +} + // Returns if a map timestamp has valid syntax // Time OR Time|Lane,Time|Lane,... func isMapTimestampValid(str string) bool { diff --git a/handlers/map_mods_test.go b/handlers/map_mods_test.go new file mode 100644 index 0000000..c367f23 --- /dev/null +++ b/handlers/map_mods_test.go @@ -0,0 +1,72 @@ +package handlers + +import "testing" + +func TestValidateEditableMapModComment(t *testing.T) { + tests := []struct { + name string + comment string + valid bool + }{ + {name: "valid", comment: "Updated explanation", valid: true}, + {name: "empty", comment: "", valid: false}, + {name: "too long", comment: string(make([]byte, 5001)), valid: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + apiErr := validateEditableMapModComment(test.comment) + + if test.valid && apiErr != nil { + t.Fatalf("expected comment to be valid, got error: %v", apiErr.Message) + } + + if !test.valid && apiErr == nil { + t.Fatal("expected comment validation to fail") + } + }) + } +} + +func TestNormalizeEditableMapModTimestamp(t *testing.T) { + validTimestamp := "12345|2,23456|4" + + tests := []struct { + name string + timestamp *string + want *string + valid bool + }{ + {name: "omitted", timestamp: nil, want: nil, valid: true}, + {name: "empty", timestamp: stringPointer(""), want: nil, valid: true}, + {name: "valid", timestamp: &validTimestamp, want: &validTimestamp, valid: true}, + {name: "invalid syntax", timestamp: stringPointer("12345|"), valid: false}, + {name: "too long", timestamp: stringPointer(string(make([]byte, 5001))), valid: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, apiErr := normalizeEditableMapModTimestamp(test.timestamp) + + if test.valid && apiErr != nil { + t.Fatalf("expected timestamp to be valid, got error: %v", apiErr.Message) + } + + if !test.valid && apiErr == nil { + t.Fatal("expected timestamp validation to fail") + } + + if test.want == nil && got != nil { + t.Fatalf("expected timestamp to be cleared, got %q", *got) + } + + if test.want != nil && (got == nil || *got != *test.want) { + t.Fatalf("expected timestamp %q, got %v", *test.want, got) + } + }) + } +} + +func stringPointer(value string) *string { + return &value +}