// Package mapping holds the manual source->Slack-email overrides that the admin // page manages. It supplements automatic email matching for when the Gitea/Notion // email differs from the Slack email (or isn't present). package mapping import ( "encoding/json" "os" "path/filepath" "sort" "sync" ) // Entry is one override row, as shown in the admin UI and stored on disk. type Entry struct { Source string `json:"source"` // gitea/notion email OR login/username SlackEmail string `json:"slack_email"` // email to look up in Slack } // Store is a thread-safe, file-backed map of Source -> SlackEmail. type Store struct { mu sync.RWMutex path string m map[string]string } // New loads the store from path (an empty store if the file is absent). func New(path string) (*Store, error) { s := &Store{path: path, m: make(map[string]string)} data, err := os.ReadFile(path) if os.IsNotExist(err) { return s, nil } if err != nil { return nil, err } var entries []Entry if len(data) > 0 { if err := json.Unmarshal(data, &entries); err != nil { return nil, err } } for _, e := range entries { s.m[e.Source] = e.SlackEmail } return s, nil } // Resolve returns the Slack email to use for a recipient. It prefers an override // keyed by email, then by login, otherwise falls back to the original email. func (s *Store) Resolve(email, login string) string { s.mu.RLock() defer s.mu.RUnlock() if email != "" { if v, ok := s.m[email]; ok { return v } } if login != "" { if v, ok := s.m[login]; ok { return v } } return email } // List returns all entries sorted by source (for stable UI rendering). func (s *Store) List() []Entry { s.mu.RLock() defer s.mu.RUnlock() out := make([]Entry, 0, len(s.m)) for k, v := range s.m { out = append(out, Entry{Source: k, SlackEmail: v}) } sort.Slice(out, func(i, j int) bool { return out[i].Source < out[j].Source }) return out } // Add inserts/updates an override and persists. func (s *Store) Add(source, slackEmail string) error { s.mu.Lock() defer s.mu.Unlock() s.m[source] = slackEmail return s.save() } // Delete removes an override and persists. func (s *Store) Delete(source string) error { s.mu.Lock() defer s.mu.Unlock() delete(s.m, source) return s.save() } // save writes the store to disk. Caller must hold the write lock. func (s *Store) save() error { entries := make([]Entry, 0, len(s.m)) for k, v := range s.m { entries = append(entries, Entry{Source: k, SlackEmail: v}) } sort.Slice(entries, func(i, j int) bool { return entries[i].Source < entries[j].Source }) data, err := json.MarshalIndent(entries, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } return os.WriteFile(s.path, data, 0o644) }