package services import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "sort" "strings" "time" "go.etcd.io/bbolt" ) var reactionsBucket = []byte("file_reactions") type ReactionService struct { db *bbolt.DB } type FileReaction struct { BoxID string `json:"boxId"` FileID string `json:"fileId"` EmojiID string `json:"emojiId"` VisitorHash string `json:"visitorHash"` CreatedAt time.Time `json:"createdAt"` } type ReactionSummary struct { EmojiID string `json:"emojiId"` Count int `json:"count"` } func NewReactionService(db *bbolt.DB) (*ReactionService, error) { if err := db.Update(func(tx *bbolt.Tx) error { _, err := tx.CreateBucketIfNotExists(reactionsBucket) return err }); err != nil { return nil, err } return &ReactionService{db: db}, nil } func (s *ReactionService) Add(boxID, fileID, visitorID, emojiID string) ([]ReactionSummary, error) { boxID = strings.TrimSpace(boxID) fileID = strings.TrimSpace(fileID) visitorHash := reactionVisitorHash(visitorID) emojiID = strings.TrimSpace(emojiID) if boxID == "" || fileID == "" || visitorHash == "" || emojiID == "" { return nil, errors.New("missing reaction data") } reaction := FileReaction{ BoxID: boxID, FileID: fileID, EmojiID: emojiID, VisitorHash: visitorHash, CreatedAt: time.Now().UTC(), } data, err := json.Marshal(reaction) if err != nil { return nil, err } key := reactionKey(boxID, fileID, visitorHash) if err := s.db.Update(func(tx *bbolt.Tx) error { bucket := tx.Bucket(reactionsBucket) if bucket.Get([]byte(key)) != nil { return os.ErrExist } return bucket.Put([]byte(key), data) }); err != nil { return nil, err } return s.SummaryForFile(boxID, fileID) } func (s *ReactionService) SummaryForBox(boxID, visitorID string) (map[string][]ReactionSummary, map[string]bool, error) { visitorHash := reactionVisitorHash(visitorID) summaries := make(map[string]map[string]int) viewerReacted := make(map[string]bool) err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(reactionsBucket) if bucket == nil { return nil } return bucket.ForEach(func(_, data []byte) error { var reaction FileReaction if err := json.Unmarshal(data, &reaction); err != nil { return err } if reaction.BoxID != boxID { return nil } if summaries[reaction.FileID] == nil { summaries[reaction.FileID] = make(map[string]int) } summaries[reaction.FileID][reaction.EmojiID]++ if visitorHash != "" && reaction.VisitorHash == visitorHash { viewerReacted[reaction.FileID] = true } return nil }) }) if err != nil { return nil, nil, err } result := make(map[string][]ReactionSummary, len(summaries)) for fileID, counts := range summaries { result[fileID] = reactionCountsToSummaries(counts) } return result, viewerReacted, nil } func (s *ReactionService) SummaryForFile(boxID, fileID string) ([]ReactionSummary, error) { counts := make(map[string]int) err := s.db.View(func(tx *bbolt.Tx) error { bucket := tx.Bucket(reactionsBucket) if bucket == nil { return nil } return bucket.ForEach(func(_, data []byte) error { var reaction FileReaction if err := json.Unmarshal(data, &reaction); err != nil { return err } if reaction.BoxID == boxID && reaction.FileID == fileID { counts[reaction.EmojiID]++ } return nil }) }) if err != nil { return nil, err } return reactionCountsToSummaries(counts), nil } func reactionCountsToSummaries(counts map[string]int) []ReactionSummary { summaries := make([]ReactionSummary, 0, len(counts)) for emojiID, count := range counts { summaries = append(summaries, ReactionSummary{EmojiID: emojiID, Count: count}) } sort.Slice(summaries, func(i, j int) bool { if summaries[i].Count == summaries[j].Count { return summaries[i].EmojiID < summaries[j].EmojiID } return summaries[i].Count > summaries[j].Count }) return summaries } func reactionKey(boxID, fileID, visitorHash string) string { return boxID + "\x00" + fileID + "\x00" + visitorHash } func reactionVisitorHash(visitorID string) string { visitorID = strings.TrimSpace(visitorID) if visitorID == "" { return "" } sum := sha256.Sum256([]byte(visitorID)) return hex.EncodeToString(sum[:]) }