347 lines
9.1 KiB
Go
347 lines
9.1 KiB
Go
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()
|
|
}
|
|
}
|