mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
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)
346 lines
9.4 KiB
Go
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())
|
|
}
|