package main import ( "errors" "net/http" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // validColors is the four-color palette mule-image always shipped. The // empty string is the explicit "clear color" sentinel. var validColors = map[string]struct{}{ "red": {}, "orange": {}, "yellow": {}, "green": {}, } // markPatch is the request body for all three mutating mark endpoints. // Pointers distinguish "field omitted" from "field set to zero" — a PUT // with `{"rating": 0}` clears the rating, but a PUT with `{"color": "red"}` // alone must NOT wipe an existing rating. type markPatch struct { Rating *int `json:"rating,omitempty"` Color *string `json:"color,omitempty"` } func (p *markPatch) sanitize() error { if p.Rating != nil { r := *p.Rating if r < 0 || r > 5 { return errors.New("rating out of range") } } if p.Color != nil { c := strings.ToLower(strings.TrimSpace(*p.Color)) if c != "" { if _, ok := validColors[c]; !ok { return errors.New("invalid color") } } *p.Color = c } return nil } // apply merges the patch onto an existing row (or a fresh zero-value // Mark for an upsert). Returns true if anything in the row still has a // non-empty value — false signals "delete the row" to the caller. func (p *markPatch) apply(m *Mark) bool { if p.Rating != nil { if *p.Rating > 0 { r := *p.Rating m.Rating = &r } else { m.Rating = nil } } if p.Color != nil { if *p.Color != "" { c := *p.Color m.Color = &c } else { m.Color = nil } } return m.Rating != nil || (m.Color != nil && *m.Color != "") } // allMarksJSON renders the entire `marks` table as the wire shape // `{"": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by // GET /photos/marks which the web client calls once on session start. func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) { var rows []Mark if err := db.Find(&rows).Error; err != nil { return nil, err } out := make(map[string]map[string]any, len(rows)) for i := range rows { out[rows[i].PhotoUID] = rows[i].asJSON() } return out, nil } func handleMarksAll(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { marks, err := allMarksJSON(db) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, marks) } } func handleMarkGet(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { uid := c.Param("uid") var m Mark err := db.Where("photo_uid = ?", uid).First(&m).Error if errors.Is(err, gorm.ErrRecordNotFound) { c.JSON(http.StatusOK, gin.H{}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, m.asJSON()) } } // upsert applies the patch and writes back. Returns the resulting JSON // shape (empty map if the row was deleted). func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) { var m Mark err := db.Where("photo_uid = ?", uid).First(&m).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } m.PhotoUID = uid keep := patch.apply(&m) m.UpdatedAt = time.Now().UTC() if !keep { // Drop the row entirely so a re-fetch returns {}. if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil { return nil, err } return map[string]any{}, nil } if err := db.Save(&m).Error; err != nil { return nil, err } return m.asJSON(), nil } func handleMarkPut(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { uid := c.Param("uid") var patch markPatch if err := c.ShouldBindJSON(&patch); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid patch"}) return } if err := patch.sanitize(); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } out, err := upsert(db, uid, &patch) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, out) } } type bulkBody struct { IDs []string `json:"ids"` Patch markPatch `json:"patch"` } func handleMarkBulk(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var body bulkBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } if len(body.IDs) == 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ids[] required"}) return } if err := body.Patch.sanitize(); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } applied := make(map[string]map[string]any, len(body.IDs)) // Single transaction so a partial failure rolls back. The client // expects atomic semantics for a bulk star/colour stamp. err := db.Transaction(func(tx *gorm.DB) error { for _, uid := range body.IDs { if uid == "" { continue } out, err := upsert(tx, uid, &body.Patch) if err != nil { return err } applied[uid] = out } return nil }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "count": len(applied), "marks": applied, }) } }