package main import ( "time" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) // Mark mirrors the per-photo extras the web client stores via the marks // endpoints — rating + four-colour label. Composite primary key // (photo_uid, user_name) so each user has independent marks. Both payload // fields are nullable so the sparse "no rating / no colour" state // round-trips cleanly. type Mark struct { PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"` UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"` Rating *int `gorm:"column:rating" json:"rating,omitempty"` Color *string `gorm:"size:16;column:color" json:"color,omitempty"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"` } // TableName pins the GORM-pluralised default to a name that matches the // other tables the M4 plan calls out (`marks`, `heap_shares`, …) so // nothing surprising lands in the schema. func (Mark) TableName() string { return "marks" } // asJSON returns the wire shape clients expect — same flat object the // Node prototype emitted. An empty Mark (rating=nil, color=nil) renders // as `{}` which the client treats as "no mark on this photo". func (m *Mark) asJSON() map[string]any { out := map[string]any{} if m == nil { return out } if m.Rating != nil { out["rating"] = *m.Rating } if m.Color != nil && *m.Color != "" { out["color"] = *m.Color } if !m.UpdatedAt.IsZero() { // ISO-8601 with millisecond precision, UTC — matches the Node // prototype's `new Date().toISOString()` so clients written against // the old endpoint stay happy. out["updatedAt"] = m.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z") } return out } func openDB(dsn string) (*gorm.DB, error) { db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{ Logger: logger.Default.LogMode(logger.Warn), }) if err != nil { return nil, err } if err := db.AutoMigrate(&Mark{}); err != nil { return nil, err } return db, nil }