package userctx import ( "context" "strings" "sync" "time" "wucher/internal/domain/user" ) // UserNameGetter is the interface used by handlers to resolve user IDs to names. type UserNameGetter interface { GetLastName(ctx context.Context, userID []byte) (string, error) } // UserFullNameGetter is an optional extension for getters that can resolve // full names (first + last). Code should prefer this when available. type UserFullNameGetter interface { GetFullName(ctx context.Context, userID []byte) (string, error) } // UserShortNameGetter is an optional extension for getters that can resolve a // user's short name (profile 3-letter code, e.g. "GAN"). Code should prefer // this when available, falling back to initials from the display name. type UserShortNameGetter interface { GetShortName(ctx context.Context, userID []byte) (string, error) } // shortNameRepo is the optional repository capability used by UserNameCache to // look up a profile short name. Only the concrete MySQL repo implements it; // keeping it out of user.Repository avoids churning every mock. type shortNameRepo interface { GetShortNameByID(ctx context.Context, id []byte) (string, error) } // initialsFromFullName derives an uppercase initials short name from a full // name: "System Admin" → "SA", "Gantner" → "G". Empty when there is no name. func initialsFromFullName(fullName string) string { parts := strings.Fields(strings.TrimSpace(fullName)) if len(parts) == 0 { return "" } if len(parts) == 1 { r := []rune(parts[0]) if len(r) == 0 { return "" } return strings.ToUpper(string(r[0])) } first := []rune(parts[0]) last := []rune(parts[len(parts)-1]) if len(first) == 0 || len(last) == 0 { return "" } return strings.ToUpper(string(first[0]) + string(last[0])) } // GlobalUserNameGetter is set once at application startup via WireUserNameGetter. // It is used by uuidStringOrEmpty helpers across packages. var GlobalUserNameGetter UserNameGetter // WireUserNameGetter configures the global user name getter. // Call this once during application startup (e.g. in main.go). func WireUserNameGetter(getter UserNameGetter) { GlobalUserNameGetter = getter } // contextKey for storing the getter in context. type contextKey string const getterKey contextKey = "userctx-getter" // WithGetter stores a UserNameGetter in the context. func WithGetter(ctx context.Context, getter UserNameGetter) context.Context { return context.WithValue(ctx, getterKey, getter) } // GetGetter retrieves the UserNameGetter from context. func GetGetter(ctx context.Context) UserNameGetter { if ctx == nil { return nil } if getter, ok := any(ctx).(interface{ UserValue(any) any }); ok { raw := getter.UserValue(string(getterKey)) if raw != nil { return raw.(UserNameGetter) } } v := ctx.Value(getterKey) if v == nil { return nil } g, _ := v.(UserNameGetter) return g } // GetDisplayName resolves a user ID to a display name. // Priority: // 1) full name from context getter (if supported) // 2) last name from context getter // 3) full name from global getter (if supported) // 4) last name from global getter func GetDisplayName(ctx context.Context, userID []byte) string { if len(userID) == 0 { return "" } type userNameGetter interface { GetLastName(context.Context, []byte) (string, error) } type userFullNameGetter interface { GetFullName(context.Context, []byte) (string, error) } // Backward compatibility: some call sites pass a context-like object that // implements getter methods directly. if fullGetter, ok := any(ctx).(userFullNameGetter); ok { if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" { return name } } if getter, ok := any(ctx).(userNameGetter); ok { if name, err := getter.GetLastName(ctx, userID); err == nil && name != "" { return name } } if getter := GetGetter(ctx); getter != nil { if fullGetter, ok := getter.(UserFullNameGetter); ok { if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" { return name } } if name, err := getter.GetLastName(ctx, userID); err == nil && name != "" { return name } } if GlobalUserNameGetter != nil { if fullGetter, ok := GlobalUserNameGetter.(UserFullNameGetter); ok { if name, err := fullGetter.GetFullName(ctx, userID); err == nil && name != "" { return name } } if name, err := GlobalUserNameGetter.GetLastName(ctx, userID); err == nil && name != "" { return name } } return "" } // GetShortName resolves a user ID to a short name (profile 3-letter code, e.g. // "GAN"), falling back to initials derived from the user's display name. // Priority mirrors GetDisplayName: context getter, then global getter, then a // display-name-initials fallback so it always returns something short. func GetShortName(ctx context.Context, userID []byte) string { if len(userID) == 0 { return "" } type shortGetter interface { GetShortName(context.Context, []byte) (string, error) } // Backward compatibility: a context-like object implementing the getter. if g, ok := any(ctx).(shortGetter); ok { if s, err := g.GetShortName(ctx, userID); err == nil && s != "" { return s } } if getter := GetGetter(ctx); getter != nil { if sg, ok := getter.(UserShortNameGetter); ok { if s, err := sg.GetShortName(ctx, userID); err == nil && s != "" { return s } } } if GlobalUserNameGetter != nil { if sg, ok := GlobalUserNameGetter.(UserShortNameGetter); ok { if s, err := sg.GetShortName(ctx, userID); err == nil && s != "" { return s } } } // Universal fallback: initials from whatever display name we can resolve. return initialsFromFullName(GetDisplayName(ctx, userID)) } // GetLastName resolves a user ID to their last name using the getter from context. // Returns an empty string if no getter is available or if userID is nil/empty. func GetLastName(ctx context.Context, userID []byte) string { if len(userID) == 0 { return "" } getter := GetGetter(ctx) if getter == nil { return "" } name, err := getter.GetLastName(ctx, userID) if err != nil { return "" } return name } // cacheEntry holds a cached last name with expiry time. type cacheEntry struct { lastName string expires time.Time } // UserNameCache is an in-memory TTL cache backed by a user.Repository. type UserNameCache struct { repo user.Repository cache map[string]cacheEntry shortCache map[string]cacheEntry mu sync.RWMutex ttl time.Duration } // NewUserNameCache creates a UserNameCache with the given TTL (default 5 minutes). func NewUserNameCache(repo user.Repository, ttl time.Duration) *UserNameCache { if ttl <= 0 { ttl = 5 * time.Minute } c := &UserNameCache{ repo: repo, cache: make(map[string]cacheEntry), shortCache: make(map[string]cacheEntry), ttl: ttl, } go c.cleanupLoop() return c } // GetShortName resolves a user ID to their profile short name (3-letter code), // falling back to initials derived from their full name. Cached with the same // TTL as names. (The cacheEntry.lastName field stores the short name here.) func (c *UserNameCache) GetShortName(ctx context.Context, userID []byte) (string, error) { if len(userID) == 0 { return "", nil } key := string(userID) c.mu.RLock() entry, ok := c.shortCache[key] c.mu.RUnlock() if ok && time.Now().Before(entry.expires) { return entry.lastName, nil } short := "" if sr, ok := c.repo.(shortNameRepo); ok { s, err := sr.GetShortNameByID(ctx, userID) if err != nil { return "", err } short = strings.TrimSpace(s) } if short == "" { // Fallback: initials derived from the full name. if full, err := c.GetFullName(ctx, userID); err == nil { short = initialsFromFullName(full) } } c.mu.Lock() c.shortCache[key] = cacheEntry{lastName: short, expires: time.Now().Add(c.ttl)} c.mu.Unlock() return short, nil } func (c *UserNameCache) GetLastName(ctx context.Context, userID []byte) (string, error) { if len(userID) == 0 { return "", nil } key := string(userID) c.mu.RLock() entry, ok := c.cache[key] c.mu.RUnlock() if ok && time.Now().Before(entry.expires) { return entry.lastName, nil } lastName, err := c.repo.GetLastNameByID(ctx, userID) if err != nil { return "", err } if lastName == "" { lastName = "(Unknown)" } c.mu.Lock() c.cache[key] = cacheEntry{lastName: lastName, expires: time.Now().Add(c.ttl)} c.mu.Unlock() return lastName, nil } func (c *UserNameCache) GetFullName(ctx context.Context, userID []byte) (string, error) { if len(userID) == 0 { return "", nil } u, err := c.repo.GetByID(ctx, userID) if err != nil { return "", err } if u == nil { return "", nil } first := "" last := "" if u.FirstName != "" { first = u.FirstName } if u.LastName != "" { last = u.LastName } if first == "" { return last, nil } if last == "" { return first, nil } return first + " " + last, nil } func (c *UserNameCache) cleanupLoop() { ticker := time.NewTicker(c.ttl * 2) for range ticker.C { c.mu.Lock() now := time.Now() for k, v := range c.cache { if now.After(v.expires) { delete(c.cache, k) } } for k, v := range c.shortCache { if now.After(v.expires) { delete(c.shortCache, k) } } c.mu.Unlock() } }