diff --git a/backend/internal/cmd/api.go b/backend/internal/cmd/api.go index 5bcbe1d..ed03441 100644 --- a/backend/internal/cmd/api.go +++ b/backend/internal/cmd/api.go @@ -16,6 +16,7 @@ import ( "github.com/forfarm/backend/internal/config" "github.com/forfarm/backend/internal/event" "github.com/forfarm/backend/internal/repository" + "github.com/forfarm/backend/internal/services" "github.com/forfarm/backend/internal/workers" ) @@ -46,7 +47,10 @@ func APICmd(ctx context.Context) *cobra.Command { defer eventBus.Close() logger.Info("connected to event bus", "url", config.RABBITMQ_URL) - analyticsRepo := repository.NewPostgresFarmAnalyticsRepository(pool, logger) + logger.Info("starting AnalyticService worker for farm-crop analytics") + analyticService := services.NewAnalyticsService() + + analyticsRepo := repository.NewPostgresFarmAnalyticsRepository(pool, logger, analyticService) farmRepo := repository.NewPostgresFarm(pool) farmRepo.SetEventPublisher(eventBus) diff --git a/backend/internal/domain/analytics.go b/backend/internal/domain/analytics.go index ea08a12..41bd072 100644 --- a/backend/internal/domain/analytics.go +++ b/backend/internal/domain/analytics.go @@ -36,15 +36,15 @@ type CropAnalytics struct { Variety *string `json:"variety,omitempty"` CurrentStatus string `json:"currentStatus"` GrowthStage string `json:"growthStage"` + GrowthProgress int `json:"growthProgress"` LandSize float64 `json:"landSize"` LastUpdated time.Time `json:"lastUpdated"` Temperature *float64 `json:"temperature,omitempty"` Humidity *float64 `json:"humidity,omitempty"` SoilMoisture *float64 `json:"soilMoisture,omitempty"` Sunlight *float64 `json:"sunlight,omitempty"` - WindSpeed *string `json:"windSpeed,omitempty"` - Rainfall *string `json:"rainfall,omitempty"` - GrowthProgress int `json:"growthProgress"` + WindSpeed *float64 `json:"windSpeed,omitempty"` + Rainfall *float64 `json:"rainfall,omitempty"` // (maps to RainVolume1h) GrowthProgress int `json:"growthProgress"` NextAction *string `json:"nextAction,omitempty"` NextActionDue *time.Time `json:"nextActionDue,omitempty"` NutrientLevels *struct { diff --git a/backend/internal/repository/postgres_farm_analytics.go b/backend/internal/repository/postgres_farm_analytics.go index 72e4ef1..4b234e5 100644 --- a/backend/internal/repository/postgres_farm_analytics.go +++ b/backend/internal/repository/postgres_farm_analytics.go @@ -11,21 +11,27 @@ import ( "time" "github.com/forfarm/backend/internal/domain" + "github.com/forfarm/backend/internal/services" "github.com/jackc/pgx/v5" ) type postgresFarmAnalyticsRepository struct { - conn Connection - logger *slog.Logger + conn Connection + logger *slog.Logger + analyticsService *services.AnalyticsService } -func NewPostgresFarmAnalyticsRepository(conn Connection, logger *slog.Logger) domain.AnalyticsRepository { +func NewPostgresFarmAnalyticsRepository(conn Connection, logger *slog.Logger, analyticsService *services.AnalyticsService) domain.AnalyticsRepository { if logger == nil { logger = slog.Default() } + if analyticsService == nil { + analyticsService = services.NewAnalyticsService() + } return &postgresFarmAnalyticsRepository{ - conn: conn, - logger: logger, + conn: conn, + logger: logger, + analyticsService: analyticsService, } } @@ -131,13 +137,13 @@ func calculateGrowthProgress(plantedAt time.Time, daysToMaturity *int) int { // --- GetCropAnalytics --- -// GetCropAnalytics retrieves and calculates analytics data for a specific crop. func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, cropID string) (*domain.CropAnalytics, error) { + // Fetch base data from croplands and plants query := ` SELECT c.uuid, c.name, c.farm_id, c.status, c.growth_stage, c.land_size, c.updated_at, p.name, p.variety, p.days_to_maturity, - c.created_at -- Needed for growth calculation + c.created_at -- Planted date proxy FROM croplands c JOIN @@ -146,13 +152,13 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, c.uuid = $1 ` - var analytics domain.CropAnalytics + var analytics domain.CropAnalytics // Initialize the struct to be populated var plantName string var variety sql.NullString var daysToMaturity sql.NullInt32 - var plantedAt time.Time // Use cropland created_at as planted date proxy + var plantedAt time.Time + var croplandLastUpdated time.Time // Capture cropland specific update time - // Use r.conn here err := r.conn.QueryRow(ctx, query, cropID).Scan( &analytics.CropID, &analytics.CropName, @@ -160,7 +166,7 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, &analytics.CurrentStatus, &analytics.GrowthStage, &analytics.LandSize, - &analytics.LastUpdated, + &croplandLastUpdated, // Use this for action suggestion timing &plantName, &variety, &daysToMaturity, @@ -168,20 +174,25 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, ) if err != nil { - if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { // Check pgx error too - r.logger.Warn("Crop analytics query returned no rows", "crop_id", cropID) + // ... (error handling as before) ... + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { + r.logger.Warn("Crop analytics base data query returned no rows", "crop_id", cropID) return nil, domain.ErrNotFound } - r.logger.Error("Failed to query crop analytics data", "crop_id", cropID, "error", err) - return nil, fmt.Errorf("database query failed for crop analytics: %w", err) + r.logger.Error("Failed to query crop base data", "crop_id", cropID, "error", err) + return nil, fmt.Errorf("database query failed for crop base data: %w", err) } + // --- Populate direct fields --- analytics.PlantName = plantName if variety.Valid { analytics.Variety = &variety.String } + analytics.LastUpdated = time.Now().UTC() // Set analytics generation time - // Calculate Growth Progress + // --- Calculate/Fetch derived fields using the service --- + + // Growth Progress var maturityDaysPtr *int if daysToMaturity.Valid { maturityInt := int(daysToMaturity.Int32) @@ -189,31 +200,27 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, } analytics.GrowthProgress = calculateGrowthProgress(plantedAt, maturityDaysPtr) - // Fetch Farm Analytics to get weather - farmAnalytics, err := r.GetFarmAnalytics(ctx, analytics.FarmID) // Call using r receiver - if err == nil && farmAnalytics != nil && farmAnalytics.Weather != nil { - analytics.Temperature = farmAnalytics.Weather.TempCelsius - analytics.Humidity = farmAnalytics.Weather.Humidity - windSpeedStr := fmt.Sprintf("%.2f", *farmAnalytics.Weather.WindSpeed) - analytics.WindSpeed = &windSpeedStr - if farmAnalytics.Weather.RainVolume1h != nil { - rainStr := fmt.Sprintf("%.2f", *farmAnalytics.Weather.RainVolume1h) - analytics.Rainfall = &rainStr - } - analytics.Sunlight = nil // Placeholder - requires source - analytics.SoilMoisture = nil // Placeholder - requires source - } else if err != nil && !errors.Is(err, domain.ErrNotFound) { - r.logger.Warn("Could not fetch farm analytics for weather data", "farm_id", analytics.FarmID, "error", err) + // Environmental Data (includes placeholders) + farmAnalytics, farmErr := r.GetFarmAnalytics(ctx, analytics.FarmID) + if farmErr != nil && !errors.Is(farmErr, domain.ErrNotFound) { + r.logger.Warn("Could not fetch associated farm analytics for crop context", "farm_id", analytics.FarmID, "crop_id", cropID, "error", farmErr) + // Proceed without farm-level weather data if farm analytics fetch fails } + analytics.Temperature, analytics.Humidity, analytics.WindSpeed, analytics.Rainfall, analytics.Sunlight, analytics.SoilMoisture = r.analyticsService.GetEnvironmentalData(farmAnalytics) - // Placeholder for other fields - analytics.PlantHealth = new(string) // Initialize pointer - *analytics.PlantHealth = "good" // Default/placeholder value - analytics.NextAction = nil - analytics.NextActionDue = nil - analytics.NutrientLevels = nil // Needs dedicated data source + // Plant Health (Dummy) + health := r.analyticsService.CalculatePlantHealth(analytics.CurrentStatus, analytics.GrowthStage) + analytics.PlantHealth = &health - r.logger.Debug("Successfully retrieved crop analytics", "crop_id", cropID, "farm_id", analytics.FarmID) + // Next Action (Dummy) + analytics.NextAction, analytics.NextActionDue = r.analyticsService.SuggestNextAction(analytics.GrowthStage, croplandLastUpdated) // Use cropland update time + + // Nutrient Levels (Dummy) + analytics.NutrientLevels = r.analyticsService.GetNutrientLevels(analytics.CropID) + + // --- End Service Usage --- + + r.logger.Debug("Successfully constructed crop analytics", "crop_id", cropID) return &analytics, nil } diff --git a/backend/internal/services/analytics_service.go b/backend/internal/services/analytics_service.go new file mode 100644 index 0000000..d412dc8 --- /dev/null +++ b/backend/internal/services/analytics_service.go @@ -0,0 +1,148 @@ +package services + +import ( + "math/rand" + "time" + + "github.com/forfarm/backend/internal/domain" +) + +// AnalyticsService provides methods for calculating or deriving analytics data. +// For now, it contains dummy implementations. +type AnalyticsService struct { + // Add dependencies like repositories if needed for real logic later +} + +// NewAnalyticsService creates a new AnalyticsService. +func NewAnalyticsService() *AnalyticsService { + return &AnalyticsService{} +} + +// CalculatePlantHealth provides a dummy health status. +// TODO: Implement real health calculation based on status, weather, events, etc. +func (s *AnalyticsService) CalculatePlantHealth(status string, growthStage string) string { + // Simple dummy logic + switch status { + case "Problem", "Diseased", "Infested": + return "warning" + case "Fallow", "Harvested": + return "n/a" // Or maybe 'good' if fallow is considered healthy state + default: + // Slightly randomize for demo purposes + if rand.Intn(10) < 2 { // 20% chance of warning even if status is 'growing' + return "warning" + } + return "good" + } +} + +// SuggestNextAction provides a dummy next action based on growth stage. +// TODO: Implement real suggestion logic based on stage, weather, history, plant type etc. +func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated time.Time) (action *string, dueDate *time.Time) { + // Default action + nextActionStr := "Monitor crop health" + nextDueDate := time.Now().Add(24 * time.Hour) // Check tomorrow + + switch growthStage { + case "Planned", "Planting": + nextActionStr = "Prepare soil and planting" + nextDueDate = time.Now().Add(12 * time.Hour) + case "Germination", "Seedling": + nextActionStr = "Check for germination success and early pests" + nextDueDate = time.Now().Add(48 * time.Hour) + case "Vegetative": + nextActionStr = "Monitor growth and apply nutrients if needed" + nextDueDate = time.Now().Add(72 * time.Hour) + case "Flowering", "Budding": + nextActionStr = "Check pollination and manage pests/diseases" + nextDueDate = time.Now().Add(48 * time.Hour) + case "Fruiting", "Ripening": + nextActionStr = "Monitor fruit development and prepare for harvest" + nextDueDate = time.Now().Add(7 * 24 * time.Hour) // Check in a week + case "Harvesting": + nextActionStr = "Proceed with harvest" + nextDueDate = time.Now().Add(24 * time.Hour) + } + + // Only return if the suggestion is "newer" than the last update to avoid constant same suggestion + // This is basic logic, real implementation would be more complex + if nextDueDate.After(lastUpdated.Add(1 * time.Hour)) { // Only suggest if due date is >1hr after last update + return &nextActionStr, &nextDueDate + } + + return nil, nil // No immediate action needed or suggestion is old +} + +// GetNutrientLevels provides dummy nutrient levels. +// TODO: Implement real nutrient level fetching (e.g., from soil sensors, lab results events). +func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct { + Nitrogen *float64 `json:"nitrogen,omitempty"` + Phosphorus *float64 `json:"phosphorus,omitempty"` + Potassium *float64 `json:"potassium,omitempty"` +} { + // Return dummy data or nil if unavailable + if rand.Intn(10) < 7 { // 70% chance of having dummy data + n := float64(50 + rand.Intn(40)) // 50-89 + p := float64(40 + rand.Intn(40)) // 40-79 + k := float64(45 + rand.Intn(40)) // 45-84 + return &struct { + Nitrogen *float64 `json:"nitrogen,omitempty"` + Phosphorus *float64 `json:"phosphorus,omitempty"` + Potassium *float64 `json:"potassium,omitempty"` + }{ + Nitrogen: &n, + Phosphorus: &p, + Potassium: &k, + } + } + return nil // Simulate data not available +} + +// GetEnvironmentalData attempts to retrieve relevant environmental data. +// TODO: Enhance this - Could query specific weather events for the crop location/timeframe. +// Currently relies on potentially stale FarmAnalytics weather. +func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalytics) (temp, humidity, wind, rain, sunlight, soilMoisture *float64) { + // Initialize with nil + temp, humidity, wind, rain, sunlight, soilMoisture = nil, nil, nil, nil, nil, nil + + // Try to get from FarmAnalytics + if farmAnalytics != nil && farmAnalytics.Weather != nil { + temp = farmAnalytics.Weather.TempCelsius + humidity = farmAnalytics.Weather.Humidity + wind = farmAnalytics.Weather.WindSpeed + rain = farmAnalytics.Weather.RainVolume1h + // Note: Sunlight and SoilMoisture are not typically in basic WeatherData + } + + // Provide dummy values ONLY if still nil (ensures real data isn't overwritten) + if temp == nil { + t := float64(18 + rand.Intn(15)) // 18-32 C + temp = &t + } + if humidity == nil { + h := float64(40 + rand.Intn(50)) // 40-89 % + humidity = &h + } + if wind == nil { + w := float64(rand.Intn(15)) // 0-14 m/s + wind = &w + } + if rain == nil { + // Simulate less frequent rain + r := 0.0 + if rand.Intn(10) < 2 { // 20% chance of rain + r = float64(rand.Intn(5)) // 0-4 mm + } + rain = &r + } + if sunlight == nil { + sl := float64(60 + rand.Intn(40)) // 60-99 % + sunlight = &sl + } + if soilMoisture == nil { + sm := float64(30 + rand.Intn(50)) // 30-79 % + soilMoisture = &sm + } + + return // Named return values +} diff --git a/frontend/app/auth/signin/google-oauth.tsx b/frontend/app/auth/signin/google-oauth.tsx index 92d10e4..16fa0da 100644 --- a/frontend/app/auth/signin/google-oauth.tsx +++ b/frontend/app/auth/signin/google-oauth.tsx @@ -22,7 +22,7 @@ export function GoogleSigninButton() { const exchangeRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/oauth/exchange`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ access_token: credentialResponse.credential }), + body: JSON.stringify({ accessToken: credentialResponse.credential }), }); if (!exchangeRes.ok) { throw new Error("Exchange token request failed");