turash/bugulma/backend/internal/matching/manager/manager.go
Damir Mukimov 000eab4740
Major repository reorganization and missing backend endpoints implementation
Repository Structure:
- Move files from cluttered root directory into organized structure
- Create archive/ for archived data and scraper results
- Create bugulma/ for the complete application (frontend + backend)
- Create data/ for sample datasets and reference materials
- Create docs/ for comprehensive documentation structure
- Create scripts/ for utility scripts and API tools

Backend Implementation:
- Implement 3 missing backend endpoints identified in gap analysis:
  * GET /api/v1/organizations/{id}/matching/direct - Direct symbiosis matches
  * GET /api/v1/users/me/organizations - User organizations
  * POST /api/v1/proposals/{id}/status - Update proposal status
- Add complete proposal domain model, repository, and service layers
- Create database migration for proposals table
- Fix CLI server command registration issue

API Documentation:
- Add comprehensive proposals.md API documentation
- Update README.md with Users and Proposals API sections
- Document all request/response formats, error codes, and business rules

Code Quality:
- Follow existing Go backend architecture patterns
- Add proper error handling and validation
- Match frontend expected response schemas
- Maintain clean separation of concerns (handler -> service -> repository)
2025-11-25 06:01:16 +01:00

346 lines
9.4 KiB
Go

package manager
import (
"context"
"encoding/json"
"fmt"
"time"
"bugulma/backend/internal/domain"
"bugulma/backend/internal/matching/engine"
)
// Context keys for extracting user/org info
type contextKey string
const (
UserIDKey contextKey = "user_id"
OrgIDKey contextKey = "org_id"
)
// Helper functions to extract context values
func getUserIDFromContext(ctx context.Context) string {
if userID, ok := ctx.Value(UserIDKey).(string); ok {
return userID
}
return "system"
}
func getOrgIDFromContext(ctx context.Context) string {
if orgID, ok := ctx.Value(OrgIDKey).(string); ok {
return orgID
}
return ""
}
// EventPublisher interface for publishing events
type EventPublisher interface {
Publish(event domain.Event) error
}
// Manager handles match lifecycle operations
type Manager struct {
matchRepo domain.MatchRepository
negotiationRepo domain.NegotiationHistoryRepository
eventBus EventPublisher
}
// NewManager creates a new lifecycle manager
func NewManager(
matchRepo domain.MatchRepository,
negotiationRepo domain.NegotiationHistoryRepository,
eventBus EventPublisher,
) *Manager {
return &Manager{
matchRepo: matchRepo,
negotiationRepo: negotiationRepo,
eventBus: eventBus,
}
}
// CreateMatch creates a new match from a candidate
func (m *Manager) CreateMatch(ctx context.Context, candidate *engine.Candidate, creatorID string) (*domain.Match, error) {
// Create match entity
match := &domain.Match{
ID: generateMatchID(),
SourceResourceID: candidate.SourceFlow.ID,
TargetResourceID: candidate.TargetFlow.ID,
Status: domain.MatchStatusSuggested,
CompatibilityScore: candidate.CompatibilityScore,
TemporalOverlapScore: candidate.TemporalScore,
QualityScore: candidate.QualityScore,
EconomicValue: candidate.EstimatedAnnualSavings,
DistanceKm: candidate.DistanceKm,
Priority: candidate.Priority,
}
// Set contract details (embedded)
now := time.Now()
terminationDate := now.Add(365 * 24 * time.Hour) // 1 year default
match.ContractDetails = &domain.ContractDetails{
EffectiveFrom: &now,
TerminationDate: &terminationDate,
ValuePerYear: candidate.EstimatedAnnualSavings,
}
// Store economic data in JSONB field
economicData, _ := json.Marshal(map[string]interface{}{
"annual_savings": candidate.EstimatedAnnualSavings,
"npv": 0,
"irr": 0,
"payback_years": 0,
"co2_avoided_tonnes": 0,
"capex_required": 0,
"opex_per_year": 0,
})
match.EconomicImpact = economicData
// Store risk data in JSONB field
riskData, _ := json.Marshal(map[string]interface{}{
"technical_risk": 0.3,
"regulatory_risk": 0.2,
"market_risk": 0.1,
"overall_risk": 0.2,
"risk_level": candidate.RiskLevel,
})
match.RiskAssessment = riskData
// Create the match
if err := m.matchRepo.Create(ctx, match); err != nil {
return nil, fmt.Errorf("failed to create match: %w", err)
}
// Add initial negotiation entry
negotiationEntry := &domain.NegotiationHistoryEntry{
MatchID: match.ID,
Timestamp: time.Now(),
ActorID: creatorID,
Action: "created",
Notes: "Match created from automated matching process",
}
if err := m.negotiationRepo.Create(ctx, negotiationEntry); err != nil {
fmt.Printf("Warning: Failed to create negotiation history: %v\n", err)
}
// Publish event
if m.eventBus != nil {
event := domain.Event{
ID: generateEventID(),
Type: domain.EventTypeMatchCreated,
EntityID: match.ID,
OrgID: getOrgIDFromContext(ctx), // Extract from request context
UserID: creatorID,
Timestamp: time.Now(),
Payload: map[string]interface{}{
"match": match,
},
}
if err := m.eventBus.Publish(event); err != nil {
fmt.Printf("Warning: Failed to publish match created event: %v\n", err)
}
}
return match, nil
}
// UpdateMatchStatus updates the status of a match with proper validation
func (m *Manager) UpdateMatchStatus(
ctx context.Context,
matchID string,
newStatus domain.MatchStatus,
actorID string,
notes string,
) error {
// Get current match
match, err := m.matchRepo.GetByID(ctx, matchID)
if err != nil {
return fmt.Errorf("failed to get match: %w", err)
}
oldStatus := match.Status
// Validate status transition
if err := m.validateStatusTransition(oldStatus, newStatus); err != nil {
return fmt.Errorf("invalid status transition: %w", err)
}
// Update match
match.Status = newStatus
// Handle status-specific logic
switch newStatus {
case domain.MatchStatusReserved:
reservedUntil := time.Now().Add(24 * time.Hour)
match.ReservedUntil = &reservedUntil
case domain.MatchStatusCancelled:
now := time.Now()
match.CancelledAt = &now
match.CancelledBy = actorID
}
if err := m.matchRepo.Update(ctx, match); err != nil {
return fmt.Errorf("failed to update match: %w", err)
}
// Add negotiation history
oldValue, _ := json.Marshal(string(oldStatus))
newValue, _ := json.Marshal(string(newStatus))
negotiationEntry := &domain.NegotiationHistoryEntry{
MatchID: matchID,
Timestamp: time.Now(),
ActorID: actorID,
Action: "status_changed",
Notes: fmt.Sprintf("Status changed from %s to %s. %s", oldStatus, newStatus, notes),
OldValue: oldValue,
NewValue: newValue,
}
if err := m.negotiationRepo.Create(ctx, negotiationEntry); err != nil {
fmt.Printf("Warning: Failed to create negotiation history: %v\n", err)
}
// Publish event
if m.eventBus != nil {
event := domain.Event{
ID: generateEventID(),
Type: domain.EventTypeMatchStatusChanged,
EntityID: matchID,
OrgID: getOrgIDFromContext(ctx), // Extract from request context
UserID: actorID,
Timestamp: time.Now(),
Payload: map[string]interface{}{
"old_status": oldStatus,
"new_status": newStatus,
"match_id": matchID,
},
}
if err := m.eventBus.Publish(event); err != nil {
fmt.Printf("Warning: Failed to publish status change event: %v\n", err)
}
}
return nil
}
// UpdateMatch updates match details
func (m *Manager) UpdateMatch(ctx context.Context, match *domain.Match) error {
match.UpdatedAt = time.Now()
if err := m.matchRepo.Update(ctx, match); err != nil {
return fmt.Errorf("failed to update match: %w", err)
}
// Publish event
if m.eventBus != nil {
event := domain.Event{
ID: generateEventID(),
Type: domain.EventTypeMatchUpdated,
EntityID: match.ID,
OrgID: getOrgIDFromContext(ctx), // Extract from request context
UserID: getUserIDFromContext(ctx), // Extract from request context
Timestamp: time.Now(),
Payload: map[string]interface{}{
"match": match,
},
}
if err := m.eventBus.Publish(event); err != nil {
fmt.Printf("Warning: Failed to publish match updated event: %v\n", err)
}
}
return nil
}
// DeleteMatch marks a match as cancelled
func (m *Manager) DeleteMatch(ctx context.Context, matchID string, actorID string) error {
match, err := m.matchRepo.GetByID(ctx, matchID)
if err != nil {
return fmt.Errorf("failed to get match: %w", err)
}
// Mark as cancelled if not already in terminal state
if match.Status != domain.MatchStatusFailed && match.Status != domain.MatchStatusCancelled {
now := time.Now()
match.Status = domain.MatchStatusCancelled
match.CancelledAt = &now
match.CancelledBy = actorID
if err := m.matchRepo.Update(ctx, match); err != nil {
return fmt.Errorf("failed to update match status: %w", err)
}
}
return nil
}
// GetNegotiationHistory retrieves negotiation history for a match
func (m *Manager) GetNegotiationHistory(ctx context.Context, matchID string) ([]*domain.NegotiationHistoryEntry, error) {
return m.negotiationRepo.GetByMatchID(ctx, matchID)
}
// validateStatusTransition validates that a status transition is allowed
func (m *Manager) validateStatusTransition(from, to domain.MatchStatus) error {
validTransitions := map[domain.MatchStatus][]domain.MatchStatus{
domain.MatchStatusSuggested: {
domain.MatchStatusNegotiating,
domain.MatchStatusReserved,
domain.MatchStatusFailed,
domain.MatchStatusCancelled,
},
domain.MatchStatusNegotiating: {
domain.MatchStatusReserved,
domain.MatchStatusContracted,
domain.MatchStatusFailed,
domain.MatchStatusCancelled,
},
domain.MatchStatusReserved: {
domain.MatchStatusContracted,
domain.MatchStatusNegotiating,
domain.MatchStatusFailed,
domain.MatchStatusCancelled,
},
domain.MatchStatusContracted: {
domain.MatchStatusLive,
domain.MatchStatusFailed,
domain.MatchStatusCancelled,
},
domain.MatchStatusLive: {
domain.MatchStatusFailed,
domain.MatchStatusCancelled,
},
// Terminal states
domain.MatchStatusFailed: {},
domain.MatchStatusCancelled: {},
}
allowedTransitions, exists := validTransitions[from]
if !exists {
return fmt.Errorf("unknown status: %s", from)
}
for _, allowed := range allowedTransitions {
if allowed == to {
return nil
}
}
return fmt.Errorf("invalid transition from %s to %s", from, to)
}
// generateMatchID generates a unique match ID
func generateMatchID() string {
return fmt.Sprintf("match_%d", time.Now().UnixNano())
}
// generateNegotiationID generates a unique negotiation entry ID
func generateNegotiationID() string {
return fmt.Sprintf("neg_%d", time.Now().UnixNano())
}
// generateEventID generates a unique event ID
func generateEventID() string {
return fmt.Sprintf("evt_%d", time.Now().UnixNano())
}