init push
This commit is contained in:
346
internal/shared/pkg/userctx/user_name_cache.go
Normal file
346
internal/shared/pkg/userctx/user_name_cache.go
Normal file
@@ -0,0 +1,346 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user