Files
notifier/DUPLICATION_ANALYSIS.md
T
2025-10-26 02:25:24 -07:00

21 KiB

Code Duplication and Refactoring Analysis - Notifier Codebase

Executive Summary

The notifier codebase shows several areas of code duplication and repeated patterns that can be refactored to improve maintainability without increasing complexity. Most duplication issues are low-to-medium complexity to address and would benefit from extraction into utility functions or helper methods.


1. REPEATED VALIDATION AND INITIALIZATION PATTERNS IN NOTIFIER IMPLEMENTATIONS

Issue 1.1: Identical Validation Pattern in All Notifier Send Methods

File Locations:

  • /Users/igodwin/Workspace/notifier/internal/notifier/smtp.go: Lines 63-70
  • /Users/igodwin/Workspace/notifier/internal/notifier/slack.go: Lines 78-85
  • /Users/igodwin/Workspace/notifier/internal/notifier/ntfy.go: Lines 185-192
  • /Users/igodwin/Workspace/notifier/internal/notifier/stdout.go: Lines 26-33

Description: Every notifier implementation repeats the same validation pattern at the start of the Send method:

if err := ValidateContext(ctx); err != nil {
    return nil, err
}

if err := s.Validate(notification); err != nil {
    return nil, err
}

Impact:

  • Code duplication (4 instances of identical pattern)
  • Maintenance burden if validation requirements change
  • Inconsistency risk if one is updated and others aren't

Refactoring Recommendation: Create an exported wrapper function in the notifier package that encapsulates this validation pattern. Could be implemented as a helper that all Send implementations call first.

// Example helper function
func ValidateNotification(ctx context.Context, notif *domain.Notification, validator domain.Notifier) error {
    if err := ValidateContext(ctx); err != nil {
        return err
    }
    return validator.Validate(notif)
}

Estimated Effort: Low (30 minutes)

  • Extract into 1 utility function
  • Update 4 notifier files
  • No behavioral changes needed

2. REPEATED HTTP REQUEST PATTERNS IN EXTERNAL NOTIFIERS

Issue 2.1: Identical HTTP Request/Response Handling in Slack and Ntfy Notifiers

File Locations:

  • /Users/igodwin/Workspace/notifier/internal/notifier/slack.go: Lines 182-211 (sendToSlack method)
  • /Users/igodwin/Workspace/notifier/internal/notifier/ntfy.go: Lines 290-323 (sendToTopic method)

Description: Both Slack and Ntfy notifiers have near-identical HTTP request handling:

jsonData, err := json.Marshal(msg/req)
if err != nil {
    return fmt.Errorf("failed to marshal: %w", err)
}

httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
    return fmt.Errorf("failed to create request: %w", err)
}

httpReq.Header.Set("Content-Type", "application/json")
// Add auth header
resp, err := s.httpClient.Do(httpReq)
if err != nil {
    return fmt.Errorf("failed to send: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
    return fmt.Errorf("API returned status: %d", resp.StatusCode)
}

Impact:

  • 25+ lines of duplicated code across 2 files
  • Difficult to maintain and update HTTP handling logic
  • Risk of inconsistent error handling between implementations
  • Makes future HTTP-related improvements (retries, timeouts, etc.) harder

Refactoring Recommendation: Create a shared HTTP utility function for JSON POST requests with authentication support.

// Helper function in notifier package
func sendJSONPostRequest(ctx context.Context, client *http.Client, 
    url string, payload interface{}, headers map[string]string) error {
    jsonData, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("failed to marshal: %w", err)
    }
    
    req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }
    
    req.Header.Set("Content-Type", "application/json")
    for k, v := range headers {
        req.Header.Set(k, v)
    }
    
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("failed to send request: %w", err)
    }
    defer resp.Body.Close()
    
    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("API returned status: %d", resp.StatusCode)
    }
    return nil
}

Estimated Effort: Low-Medium (45 minutes)

  • Create 1 utility function in notifier package
  • Refactor 2 send methods to use utility
  • Comprehensive testing needed

3. REPEATED FILTER MATCHING LOGIC

Issue 3.1: Identical List Membership Check Pattern in matchesFilter

File Location:

  • /Users/igodwin/Workspace/notifier/internal/service/service.go: Lines 481-540

Description: The matchesFilter method checks if a notification matches filters using the same pattern repeated 4 times:

if len(filter.IDs) > 0 {
    found := false
    for _, id := range filter.IDs {
        if notification.ID == id {
            found = true
            break
        }
    }
    if !found {
        return false
    }
}
// ... repeated for Types, Statuses, Recipients with nearly identical logic

This pattern is repeated for:

  • IDs (lines 481-492)
  • Types (lines 495-506)
  • Statuses (lines 509-520)
  • Recipients (with nested loop, lines 523-539)

Impact:

  • 60+ lines with highly repetitive matching logic
  • Hard to maintain and extend if matching logic needs changes
  • Error-prone for adding new filter fields
  • Readability suffers due to repetitive structure

Refactoring Recommendation: Create a generic "contains" helper function and use it for simpler cases. For recipients, create a dedicated function.

// Helper function
func containsValue[T comparable](haystack []T, needle T) bool {
    for _, h := range haystack {
        if h == needle {
            return true
        }
    }
    return false
}

// In matchesFilter:
if len(filter.IDs) > 0 && !containsValue(filter.IDs, notification.ID) {
    return false
}

if len(filter.Types) > 0 && !containsValue(filter.Types, notification.Type) {
    return false
}

if len(filter.Statuses) > 0 && !containsValue(filter.Statuses, notification.Status) {
    return false
}

// For recipients (more complex):
if len(filter.Recipients) > 0 && !notificationHasRecipient(notification, filter.Recipients) {
    return false
}

func notificationHasRecipient(notif *domain.Notification, recipients []string) bool {
    for _, fr := range recipients {
        for _, nr := range notif.Recipients {
            if fr == nr {
                return true
            }
        }
    }
    return false
}

Estimated Effort: Low (40 minutes)

  • Create 2 helper functions
  • Refactor matchesFilter method
  • Unit tests for helpers
  • Reduces 60+ lines to ~30 lines

4. REPEATED AUTHENTICATION VALIDATION LOGIC

Issue 4.1: Duplicated API Key Validation in REST and gRPC Middleware

File Locations:

  • /Users/igodwin/Workspace/notifier/internal/auth/rest_middleware.go: Lines 35-50
  • /Users/igodwin/Workspace/notifier/internal/auth/grpc_middleware.go: Lines 38-50 (Unary), Lines 82-94 (Stream)

Description: Both REST and gRPC middleware repeat the same validation sequence:

// Validate API key
key, err := m.store.ValidateKey(apiKey)
if err != nil {
    // log error
    // return auth error
}

// Check rate limit
allowed, err := m.store.CheckRateLimit(apiKey)
if err != nil || !allowed {
    // log error
    // return rate limit error
}

// Update last used
if err := m.store.UpdateLastUsed(apiKey); err != nil {
    // log error
}

// Create auth context
authCtx := &AuthContext{...}

This pattern appears 3 times (REST middleware once, gRPC unary once, gRPC stream once).

Impact:

  • 30+ lines of duplicated validation logic across 2 files
  • Changes to validation logic must be made in 3 places
  • Inconsistency risk between REST and gRPC authentication
  • Testing burden increased

Refactoring Recommendation: Extract validation into a reusable helper method in APIKeyStore.

type ValidatedKey struct {
    APIKey   *APIKey
    ClientID string
    Roles    []string
}

// In APIKeyStore:
func (s *APIKeyStore) ValidateAndAuthorize(keyStr string) (*ValidatedKey, error) {
    // Validate key
    key, err := s.ValidateKey(keyStr)
    if err != nil {
        return nil, err
    }
    
    // Check rate limit
    allowed, err := s.CheckRateLimit(keyStr)
    if err != nil || !allowed {
        return nil, fmt.Errorf("rate limit exceeded")
    }
    
    // Update last used
    if err := s.UpdateLastUsed(keyStr); err != nil {
        // Log but don't fail
        return nil, err
    }
    
    return &ValidatedKey{
        APIKey:   key,
        ClientID: key.ClientID,
        Roles:    key.Roles,
    }, nil
}

Then in both middlewares:

validatedKey, err := m.store.ValidateAndAuthorize(apiKey)
if err != nil {
    // return auth error
}

authCtx := &AuthContext{
    APIKey:   validatedKey.APIKey,
    ClientID: validatedKey.ClientID,
    Roles:    validatedKey.Roles,
}

Estimated Effort: Low-Medium (50 minutes)

  • Add helper method to APIKeyStore
  • Refactor 3 middleware methods
  • Update error handling to match
  • Better error semantics (different errors for different failures)

5. REPEATED NOTIFIER REGISTRATION PATTERN

Issue 5.1: Identical Notifier Registration Logging in Main

File Location:

  • /Users/igodwin/Workspace/notifier/cmd/server/main.go: Lines 193-241

Description: The registerNotifiers function repeats the same registration pattern 3 times (for SMTP, Slack, Ntfy):

for accountName, smtpConfig := range cfg.Notifiers.SMTP {
    notifier, err := notifier.NewSMTPNotifier(smtpConfig)
    if err != nil {
        logger.Warnf("Failed to create ... notifier for account '%s': %v", accountName, err)
    } else {
        if err := factory.RegisterNotifier(domain.TypeEmail, accountName, notifier); err != nil {
            logger.Fatalf("Failed to register ... notifier for account '%s': %v", accountName, err)
        }
        defaultStr := ""
        if smtpConfig.Default {
            defaultStr = " (default)"
        }
        logger.Infof("Registered ... notifier for account '%s'%s", accountName, defaultStr)
    }
}
// ... repeat for Slack ...
// ... repeat for Ntfy ...

Impact:

  • 50+ lines with highly repetitive registration logic
  • Maintenance burden - any changes must be made in 3 places
  • Hard to add new notifier types
  • Inconsistent error handling between types requires careful duplication

Refactoring Recommendation: Create a generic registration helper function.

type NotifierConfig interface {
    Default() bool
}

func registerNotifierType[T NotifierConfig](
    cfg map[string]T,
    notifierType domain.NotificationType,
    factory *notifier.Factory,
    logger *logging.Logger,
    creator func(T) (domain.Notifier, error),
    creatorName string) {
    
    for accountName, config := range cfg {
        notif, err := creator(config)
        if err != nil {
            logger.Warnf("Failed to create %s notifier for account '%s': %v", 
                creatorName, accountName, err)
        } else {
            if err := factory.RegisterNotifier(notifierType, accountName, notif); err != nil {
                logger.Fatalf("Failed to register %s notifier for account '%s': %v",
                    creatorName, accountName, err)
            }
            defaultStr := ""
            if config.Default() {
                defaultStr = " (default)"
            }
            logger.Infof("Registered %s notifier for account '%s'%s", 
                creatorName, accountName, defaultStr)
        }
    }
}

// In registerNotifiers:
registerNotifierType(cfg.Notifiers.SMTP, domain.TypeEmail, factory, logger,
    func(c *notifier.SMTPConfig) (domain.Notifier, error) {
        return notifier.NewSMTPNotifier(c)
    }, "SMTP")

Estimated Effort: Medium (1 hour)

  • Refactor config structs to implement interface
  • Create generic registration function
  • Update 3 registration calls
  • Add type-specific naming

6. REPEATED DEFAULT ACCOUNT RESOLUTION PATTERN

Issue 6.1: Identical Default Account Search in Config

File Location:

  • /Users/igodwin/Workspace/notifier/internal/config/config.go: Lines 346-380

Description: The GetDefaultAccount method repeats the same search pattern 3 times:

switch notifierType {
case domain.TypeEmail:
    for name, cfg := range c.Notifiers.SMTP {
        if cfg.Default {
            return name
        }
    }
    // Return first account if no default is set
    for name := range c.Notifiers.SMTP {
        return name
    }
// ... repeat for TypeSlack ...
// ... repeat for TypeNtfy ...
}

Impact:

  • 35+ lines with repetitive logic
  • Hard to maintain - changes needed in 3 places
  • Error-prone for adding new notifier types
  • The "get default or first" pattern is repeated 3 times

Refactoring Recommendation: Create a generic helper function for finding default in a config map.

// Helper function
func getDefaultOrFirst[T interface{ Default() bool }](configs map[string]T) string {
    // First pass: find explicitly marked default
    for name, cfg := range configs {
        if cfg.Default() {
            return name
        }
    }
    // Second pass: return first if no default
    for name := range configs {
        return name
    }
    return ""
}

// Update config structs to implement interface:
func (s *notifier.SMTPConfig) Default() bool { return s.Default }

// In GetDefaultAccount:
switch notifierType {
case domain.TypeEmail:
    return getDefaultOrFirst(c.Notifiers.SMTP)
case domain.TypeSlack:
    return getDefaultOrFirst(c.Notifiers.Slack)
case domain.TypeNtfy:
    return getDefaultOrFirst(c.Notifiers.Ntfy)
}

Estimated Effort: Low-Medium (45 minutes)

  • Create generic helper function
  • Refactor config structs to expose Default() method
  • Update GetDefaultAccount to use helper
  • Reduces 35+ lines to ~15 lines

7. REPEATED NOTIFICATIONRESULT ERROR CREATION

Issue 7.1: Duplicated NotificationResult Creation with Error

File Locations:

  • /Users/igodwin/Workspace/notifier/internal/notifier/smtp.go: Lines 80-86, Lines 100-105
  • /Users/igodwin/Workspace/notifier/internal/notifier/slack.go: Lines 93-98
  • /Users/igodwin/Workspace/notifier/internal/notifier/ntfy.go: Lines 268-273

Description: All notifier implementations repeat a pattern for creating error results:

return &domain.NotificationResult{
    NotificationID: notification.ID,
    Success:        false,
    Error:          err.Error(),
    SentAt:         time.Now(),
}, fmt.Errorf("...")

This pattern (with variations) appears 4+ times across different notifier files.

Impact:

  • Duplicated error result creation
  • Inconsistent handling of SentAt timestamp
  • Risk of incomplete error results

Refactoring Recommendation: Create a helper in the BaseNotifier for error results.

// In BaseNotifier:
func (b *BaseNotifier) ErrorResult(notificationID, errorMsg string, err error) *domain.NotificationResult {
    return &domain.NotificationResult{
        NotificationID: notificationID,
        Success:        false,
        Error:          errorMsg,
        SentAt:         time.Now(),
    }
}

// Usage in notifiers:
return b.ErrorResult(notification.ID, err.Error(), err), err

Estimated Effort: Low (30 minutes)

  • Add helper method to BaseNotifier
  • Update 4 error creation sites

8. REPEATED LOCK/UNLOCK PATTERNS IN API KEY STORE

Issue 8.2: Similar RWMutex Lock Patterns

File Location:

  • /Users/igodwin/Workspace/notifier/internal/auth/auth.go: Multiple methods

Description: Multiple methods in APIKeyStore follow the same pattern:

s.mu.RLock()
defer s.mu.RUnlock()
// read-only operations

And for write operations:

s.mu.Lock()
defer s.mu.Unlock()
// write operations

While this is acceptable Go practice, it's a repeated pattern that could be standardized.

Impact:

  • Not a critical issue - proper defer usage is correct
  • Consistent approach across codebase
  • Could be improved by adding higher-level thread-safe methods

Refactoring Recommendation: This is acceptable as-is. The repetition is appropriate for the pattern.

Estimated Effort: N/A - Not recommended for change


9. REPEATED TYPE CONVERSION FUNCTIONS IN GRPC HANDLER

Issue 9.1: Multiple Similar Type Conversion Functions

File Location:

  • /Users/igodwin/Workspace/notifier/api/grpc/handler.go: Lines 279-449

Description: Multiple conversion functions follow the same switch-case pattern:

  • convertProtoTypeToDomain (lines 279-292)
  • convertDomainTypeToProto (lines 294-307)
  • convertDomainToProtoType (lines 331-344) - DUPLICATE of above
  • convertProtoContentTypeToDomain (lines 309-318)
  • convertDomainContentTypeToProto (lines 320-329)
  • convertDomainToProtoStatus (lines 346-363)

Lines 331-344 are a duplicate of lines 294-307!

Impact:

  • Exact duplication: convertDomainTypeToProto and convertDomainToProtoType do the same thing
  • Code maintenance burden
  • Risk of inconsistency between duplicate functions
  • Takes up 30+ unnecessary lines

Refactoring Recommendation: Remove the duplicate convertDomainToProtoType function and use convertDomainTypeToProto instead.

Search all files for usages:

  • convertDomainToProtoType is used at line 368 in convertDomainToProtoNotification
  • Replace this single usage with convertDomainTypeToProto
  • Delete lines 331-344
// REMOVE THIS (duplicate):
func convertDomainToProtoType(domainType domain.NotificationType) pb.NotificationType {
    // ... identical to convertDomainTypeToProto ...
}

// USE THIS INSTEAD:
func convertDomainTypeToProto(domainType domain.NotificationType) pb.NotificationType {
    // existing implementation
}

// Update line 368:
- Type:       convertDomainToProtoType(notif.Type),
+ Type:       convertDomainTypeToProto(notif.Type),

Estimated Effort: Low (15 minutes)

  • Remove duplicate function (14 lines)
  • Update 1 call site
  • Simple find-and-replace

10. REPEATED REST RESPONSE HELPER PATTERN

Issue 10.1: REST Response Helpers as Only Response Utility

File Location:

  • /Users/igodwin/Workspace/notifier/api/rest/handlers.go: Lines 264-284

Description: The REST handler has two response helper functions that format responses:

func respondJSON(w http.ResponseWriter, status int, data interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    if err := json.NewEncoder(w).Encode(data); err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
    }
}

func respondError(w http.ResponseWriter, status int, message string, err error) {
    errMsg := message
    if err != nil {
        errMsg = message + ": " + err.Error()
    }
    respondJSON(w, status, map[string]interface{}{
        "error":   message,
        "details": errMsg,
    })
}

These are well-factored helpers already, but they're not shared with gRPC handler if it needs similar utilities.

Impact:

  • REST-specific utilities (proper placement)
  • gRPC has different response patterns (also proper)
  • No duplication issue identified

Refactoring Recommendation: Keep as-is. These are properly scoped to REST handler.

Estimated Effort: N/A - Not recommended for change


SUMMARY TABLE OF REFACTORING OPPORTUNITIES

Issue Location Type Lines Effort Priority Impact
1.1 Notifier Send validation Repeated pattern 4x 8 lines Low Medium Maintainability
2.1 HTTP request handling Code duplication 25+ lines Low-Med High Maintainability + Testing
3.1 Filter matching logic Repeated pattern 60+ lines Low High Readability + Extensibility
4.1 Auth validation Code duplication 30+ lines Low-Med Medium Maintainability + Consistency
5.1 Notifier registration Repeated pattern 50+ lines Medium Medium Extensibility
6.1 Default account search Repeated pattern 35+ lines Low-Med Low Code cleanliness
7.1 Error result creation Repeated pattern 4x 5 lines Low Low Consistency
9.1 Type conversion (DUPLICATE) Exact duplication 14 lines Low High Code cleanliness

IMPLEMENTATION PRIORITY

Phase 1 (High Impact, Low Effort - Do First)

  1. Issue 9.1 - Remove duplicate convertDomainToProtoType (15 min)
  2. Issue 1.1 - Extract validation pattern (30 min)
  3. Issue 7.1 - Add error result helper (30 min)

Phase 2 (Medium Impact, Low-Medium Effort)

  1. Issue 3.1 - Refactor filter matching (40 min)
  2. Issue 4.1 - Extract auth validation (50 min)
  3. Issue 2.1 - Create HTTP helper (45 min)

Phase 3 (Good to Have, Medium Effort)

  1. Issue 5.1 - Generic registration function (60 min)
  2. Issue 6.1 - Generic default resolution (45 min)

RISK ASSESSMENT

All recommended refactorings are LOW RISK because:

  • They extract existing patterns without changing behavior
  • No API changes or breaking changes
  • All can be easily tested with existing test suite
  • Changes are localized to internal utilities
  • Incremental refactoring possible (Phase 1, 2, 3)