fix: fix farm openweather projection

This commit is contained in:
Sosokker 2025-04-04 15:30:21 +07:00
parent b7e3e0a164
commit 1e6c631be3
11 changed files with 343 additions and 324 deletions

View File

@ -32,6 +32,7 @@ func (f *Farm) Validate() error {
type FarmRepository interface { type FarmRepository interface {
GetByID(context.Context, string) (*Farm, error) GetByID(context.Context, string) (*Farm, error)
GetByOwnerID(context.Context, string) ([]Farm, error) GetByOwnerID(context.Context, string) ([]Farm, error)
GetAll(context.Context) ([]Farm, error)
CreateOrUpdate(context.Context, *Farm) error CreateOrUpdate(context.Context, *Farm) error
Delete(context.Context, string) error Delete(context.Context, string) error
SetEventPublisher(EventPublisher) SetEventPublisher(EventPublisher)

View File

@ -45,6 +45,7 @@ func (p *Plant) Validate() error {
type PlantRepository interface { type PlantRepository interface {
GetByUUID(context.Context, string) (Plant, error) GetByUUID(context.Context, string) (Plant, error)
GetAll(context.Context) ([]Plant, error) GetAll(context.Context) ([]Plant, error)
GetByName(context.Context, string) (Plant, error)
Create(context.Context, *Plant) error Create(context.Context, *Plant) error
Update(context.Context, *Plant) error Update(context.Context, *Plant) error
Delete(context.Context, string) error Delete(context.Context, string) error

View File

@ -1,4 +1,3 @@
// backend/internal/event/projection.go
package event package event
import ( import (
@ -35,11 +34,10 @@ func NewFarmAnalyticsProjection(
func (p *FarmAnalyticsProjection) Start(ctx context.Context) error { func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
eventTypes := []string{ eventTypes := []string{
"farm.created", "farm.updated", "farm.deleted", // Farm lifecycle "farm.created", "farm.updated", "farm.deleted",
"weather.updated", // Weather updates "weather.updated",
"cropland.created", "cropland.updated", "cropland.deleted", // Crop changes trigger count recalc "cropland.created", "cropland.updated", "cropland.deleted",
"inventory.item.created", "inventory.item.updated", "inventory.item.deleted", // Inventory changes trigger timestamp update "inventory.item.created", "inventory.item.updated", "inventory.item.deleted",
// Add other events that might influence FarmAnalytics, e.g., "pest.detected", "yield.recorded"
} }
p.logger.Info("FarmAnalyticsProjection starting, subscribing to events", "types", eventTypes) p.logger.Info("FarmAnalyticsProjection starting, subscribing to events", "types", eventTypes)
@ -49,8 +47,6 @@ func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
if err := p.eventSubscriber.Subscribe(ctx, eventType, p.handleEvent); err != nil { if err := p.eventSubscriber.Subscribe(ctx, eventType, p.handleEvent); err != nil {
p.logger.Error("Failed to subscribe to event type", "type", eventType, "error", err) p.logger.Error("Failed to subscribe to event type", "type", eventType, "error", err)
errs = append(errs, fmt.Errorf("failed to subscribe to %s: %w", eventType, err)) errs = append(errs, fmt.Errorf("failed to subscribe to %s: %w", eventType, err))
// TODO: Decide if we should continue subscribing or fail hard
// return errors.Join(errs...) // Fail hard
} else { } else {
p.logger.Info("Successfully subscribed to event type", "type", eventType) p.logger.Info("Successfully subscribed to event type", "type", eventType)
} }
@ -65,33 +61,30 @@ func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
} }
func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error { func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // 10-second timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
p.logger.Debug("Handling event in FarmAnalyticsProjection", "type", event.Type, "aggregate_id", event.AggregateID, "event_id", event.ID) p.logger.Debug("Handling event in FarmAnalyticsProjection", "type", event.Type, "aggregate_id", event.AggregateID, "event_id", event.ID)
farmID := event.AggregateID // Assume AggregateID is the Farm UUID for relevant events farmID := event.AggregateID
// Special case: inventory events might use UserID as AggregateID. // Try to get farmID from payload if AggregateID is empty or potentially not the farmID (e.g., user events)
// Need a way to map UserID to FarmID if necessary, or adjust event publishing. if farmID == "" || event.Type == "inventory.item.created" || event.Type == "inventory.item.updated" || event.Type == "inventory.item.deleted" || event.Type == "cropland.created" || event.Type == "cropland.updated" || event.Type == "cropland.deleted" {
// For now, we assume farmID can be derived or is directly in the payload for inventory events.
if farmID == "" {
payloadMap, ok := event.Payload.(map[string]interface{}) payloadMap, ok := event.Payload.(map[string]interface{})
if ok { if ok {
if idVal, ok := payloadMap["farm_id"].(string); ok && idVal != "" { if idVal, ok := payloadMap["farm_id"].(string); ok && idVal != "" {
farmID = idVal farmID = idVal
} else if idVal, ok := payloadMap["user_id"].(string); ok && idVal != "" { } else if event.Type != "farm.deleted" && event.Type != "farm.created" {
// !! WARNING: Need mapping from user_id to farm_id here !! p.logger.Warn("Could not determine farm_id from event payload or AggregateID", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID)
// This is a temp - requires adding userRepo or similar lookup return nil
p.logger.Warn("Inventory event received without direct farm_id, cannot update stats", "event_id", event.ID, "user_id", idVal) }
// Skip inventory stats update if farm_id is missing } else if event.Type != "farm.deleted" && event.Type != "farm.created" {
p.logger.Error("Event payload is not a map, cannot extract farm_id", "event_type", event.Type, "event_id", event.ID)
return nil return nil
} }
} }
}
if farmID == "" && event.Type != "farm.deleted" { // farm.deleted uses AggregateID which is the farmID being deleted if farmID == "" && event.Type != "farm.deleted" {
p.logger.Error("Cannot process event, missing farm_id", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID) p.logger.Error("Cannot process event, missing farm_id", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID)
return nil return nil
} }
@ -99,22 +92,21 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
var err error var err error
switch event.Type { switch event.Type {
case "farm.created", "farm.updated": case "farm.created", "farm.updated":
// Need to get the full Farm domain object from the payload
var farmData domain.Farm var farmData domain.Farm
jsonData, _ := json.Marshal(event.Payload) // Convert payload map back to JSON jsonData, _ := json.Marshal(event.Payload)
if err = json.Unmarshal(jsonData, &farmData); err != nil { if err = json.Unmarshal(jsonData, &farmData); err != nil {
p.logger.Error("Failed to unmarshal farm data from event payload", "event_id", event.ID, "error", err) p.logger.Error("Failed to unmarshal farm data from event payload", "event_id", event.ID, "error", err)
// Nack or Ack based on error strategy? Ack for now.
return nil return nil
} }
// Ensure UUID is set from AggregateID if missing in payload itself
if farmData.UUID == "" { if farmData.UUID == "" {
farmData.UUID = event.AggregateID farmData.UUID = event.AggregateID
} }
p.logger.Info("Processing farm event", "event_type", event.Type, "farm_id", farmData.UUID, "owner_id", farmData.OwnerID)
err = p.repository.CreateOrUpdateFarmBaseData(ctx, &farmData) err = p.repository.CreateOrUpdateFarmBaseData(ctx, &farmData)
case "farm.deleted": case "farm.deleted":
farmID = event.AggregateID // Use AggregateID directly for delete farmID = event.AggregateID
if farmID == "" { if farmID == "" {
p.logger.Error("Cannot process farm.deleted event, missing farm_id in AggregateID", "event_id", event.ID) p.logger.Error("Cannot process farm.deleted event, missing farm_id in AggregateID", "event_id", event.ID)
return nil return nil
@ -122,12 +114,11 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
err = p.repository.DeleteFarmAnalytics(ctx, farmID) err = p.repository.DeleteFarmAnalytics(ctx, farmID)
case "weather.updated": case "weather.updated":
// Extract weather data from payload
var weatherData domain.WeatherData var weatherData domain.WeatherData
jsonData, _ := json.Marshal(event.Payload) jsonData, _ := json.Marshal(event.Payload)
if err = json.Unmarshal(jsonData, &weatherData); err != nil { if err = json.Unmarshal(jsonData, &weatherData); err != nil {
p.logger.Error("Failed to unmarshal weather data from event payload", "event_id", event.ID, "error", err) p.logger.Error("Failed to unmarshal weather data from event payload", "event_id", event.ID, "error", err)
return nil // Acknowledge bad data return nil
} }
err = p.repository.UpdateFarmAnalyticsWeather(ctx, farmID, &weatherData) err = p.repository.UpdateFarmAnalyticsWeather(ctx, farmID, &weatherData)
@ -146,8 +137,6 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
err = p.repository.UpdateFarmAnalyticsCropStats(ctx, farmID) err = p.repository.UpdateFarmAnalyticsCropStats(ctx, farmID)
case "inventory.item.created", "inventory.item.updated", "inventory.item.deleted": case "inventory.item.created", "inventory.item.updated", "inventory.item.deleted":
// farmID needs to be looked up or present in payload
// For now, we only touch the timestamp
if farmID != "" { if farmID != "" {
err = p.repository.UpdateFarmAnalyticsInventoryStats(ctx, farmID) err = p.repository.UpdateFarmAnalyticsInventoryStats(ctx, farmID)
} else { } else {
@ -162,7 +151,6 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
if err != nil { if err != nil {
p.logger.Error("Failed to update farm analytics", "event_type", event.Type, "farm_id", farmID, "error", err) p.logger.Error("Failed to update farm analytics", "event_type", event.Type, "farm_id", farmID, "error", err)
// Decide whether to return the error (potentially causing requeue) or nil (ack)
return nil return nil
} }

View File

@ -124,19 +124,18 @@ func (p *postgresCroplandRepository) CreateOrUpdate(ctx context.Context, c *doma
if c.GeoFeature != nil { if c.GeoFeature != nil {
_ = json.Unmarshal(c.GeoFeature, &geoFeatureMap) _ = json.Unmarshal(c.GeoFeature, &geoFeatureMap)
} }
payload := map[string]interface{}{ payload := map[string]interface{}{
"crop_id": c.UUID, "uuid": c.UUID,
"name": c.Name, "name": c.Name,
"status": c.Status, "status": c.Status,
"priority": c.Priority, "priority": c.Priority,
"land_size": c.LandSize, "landSize": c.LandSize,
"growth_stage": c.GrowthStage, "growthStage": c.GrowthStage,
"plant_id": c.PlantID, "plantId": c.PlantID,
"farm_id": c.FarmID, "farmId": c.FarmID,
"geo_feature": geoFeatureMap, "geoFeature": geoFeatureMap,
"created_at": c.CreatedAt, "createdAt": c.CreatedAt,
"updated_at": c.UpdatedAt, "updatedAt": c.UpdatedAt,
"event_type": eventType, "event_type": eventType,
} }

View File

@ -2,11 +2,13 @@ package repository
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
) )
type postgresFarmRepository struct { type postgresFarmRepository struct {
@ -94,6 +96,47 @@ func (p *postgresFarmRepository) fetchCroplandsByFarmIDs(ctx context.Context, fa
return croplandsByFarmID, nil return croplandsByFarmID, nil
} }
func (p *postgresFarmRepository) GetAll(ctx context.Context) ([]domain.Farm, error) {
// Query to select all farms, ordered by creation date for consistency
query := `
SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
FROM farms
ORDER BY created_at DESC`
// Use the existing fetch method without specific arguments for filtering
farms, err := p.fetch(ctx, query)
if err != nil {
return nil, err
}
if len(farms) == 0 {
return []domain.Farm{}, nil // Return empty slice, not nil
}
// --- Fetch associated crops (optional but good for consistency) ---
farmIDs := make([]string, 0, len(farms))
farmMap := make(map[string]*domain.Farm, len(farms))
for i := range farms {
farmIDs = append(farmIDs, farms[i].UUID)
farmMap[farms[i].UUID] = &farms[i]
}
croplandsByFarmID, err := p.fetchCroplandsByFarmIDs(ctx, farmIDs)
if err != nil {
// Log the warning but return the farms fetched so far
// Depending on requirements, you might want to return the error instead
println("Warning: Failed to fetch associated croplands during GetAll:", err.Error())
} else {
for farmID, croplands := range croplandsByFarmID {
if farm, ok := farmMap[farmID]; ok {
farm.Crops = croplands
}
}
}
// --- End Fetch associated crops ---
return farms, nil
}
func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*domain.Farm, error) { func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*domain.Farm, error) {
query := ` query := `
SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
@ -112,8 +155,21 @@ func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*d
&f.OwnerID, &f.OwnerID,
) )
if err != nil { if err != nil {
return nil, err if errors.Is(err, pgx.ErrNoRows) { // Check for pgx specific error
return nil, domain.ErrNotFound
} }
return nil, err // Return other errors
}
// Fetch associated crops (optional, depends if GetByID needs them)
cropsMap, err := p.fetchCroplandsByFarmIDs(ctx, []string{f.UUID})
if err != nil {
println("Warning: Failed to fetch croplands for GetByID:", err.Error())
// Decide whether to return the farm without crops or return the error
} else if crops, ok := cropsMap[f.UUID]; ok {
f.Crops = crops
}
return &f, nil return &f, nil
} }
@ -192,14 +248,16 @@ func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.F
Timestamp: time.Now(), Timestamp: time.Now(),
AggregateID: f.UUID, AggregateID: f.UUID,
Payload: map[string]interface{}{ Payload: map[string]interface{}{
"farm_id": f.UUID, "uuid": f.UUID,
"name": f.Name, "name": f.Name,
"lat": f.Lat,
"lon": f.Lon,
"location": map[string]float64{"lat": f.Lat, "lon": f.Lon}, "location": map[string]float64{"lat": f.Lat, "lon": f.Lon},
"farm_type": f.FarmType, "farmType": f.FarmType,
"total_size": f.TotalSize, "totalSize": f.TotalSize,
"owner_id": f.OwnerID, "ownerId": f.OwnerID,
"created_at": f.CreatedAt, "createdAt": f.CreatedAt,
"updated_at": f.UpdatedAt, "updatedAt": f.UpdatedAt,
}, },
} }

View File

@ -59,7 +59,7 @@ func (r *postgresFarmAnalyticsRepository) GetFarmAnalytics(ctx context.Context,
&analytics.OwnerID, &analytics.OwnerID,
&farmType, &farmType,
&totalSize, &totalSize,
&analytics.Latitude, // Scan directly into the struct fields &analytics.Latitude,
&analytics.Longitude, &analytics.Longitude,
&weatherJSON, &weatherJSON,
&inventoryJSON, &inventoryJSON,
@ -228,16 +228,16 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context,
func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error { func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error {
query := ` query := `
INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, lat, lon, last_updated) INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, analytics_last_updated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (farm_id) DO UPDATE ON CONFLICT (farm_id) DO UPDATE
SET farm_name = EXCLUDED.farm_name, SET farm_name = EXCLUDED.farm_name,
owner_id = EXCLUDED.owner_id, owner_id = EXCLUDED.owner_id,
farm_type = EXCLUDED.farm_type, farm_type = EXCLUDED.farm_type,
total_size = EXCLUDED.total_size, total_size = EXCLUDED.total_size,
lat = EXCLUDED.lat, latitude = EXCLUDED.latitude,
lon = EXCLUDED.lon, longitude = EXCLUDED.longitude,
last_updated = EXCLUDED.last_updated;` analytics_last_updated = EXCLUDED.analytics_last_updated;`
_, err := r.conn.Exec(ctx, query, _, err := r.conn.Exec(ctx, query,
farm.UUID, farm.UUID,
@ -259,158 +259,100 @@ func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error { func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error {
if weatherData == nil { if weatherData == nil {
return fmt.Errorf("weather data cannot be nil") return errors.New("weather data cannot be nil for update")
} }
weatherJSON, err := json.Marshal(weatherData)
if err != nil {
r.logger.Error("Failed to marshal weather data for analytics update", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to marshal weather data: %w", err)
}
query := ` query := `
UPDATE farm_analytics UPDATE public.farm_analytics SET
SET weather_data = $1, weather_temp_celsius = $2,
last_updated = $2 weather_humidity = $3,
WHERE farm_id = $3;` weather_description = $4,
weather_icon = $5,
weather_wind_speed = $6,
weather_rain_1h = $7,
weather_observed_at = $8,
weather_last_updated = NOW(), -- Use current time for the update time
analytics_last_updated = NOW()
WHERE farm_id = $1`
cmdTag, err := r.conn.Exec(ctx, query, weatherJSON, time.Now().UTC(), farmID) _, err := r.conn.Exec(ctx, query,
farmID,
weatherData.TempCelsius,
weatherData.Humidity,
weatherData.Description,
weatherData.Icon,
weatherData.WindSpeed,
weatherData.RainVolume1h,
weatherData.ObservedAt,
)
if err != nil { if err != nil {
r.logger.Error("Failed to update farm analytics weather data", "farm_id", farmID, "error", err) r.logger.Error("Error updating farm weather analytics", "farm_id", farmID, "error", err)
return fmt.Errorf("database update failed for weather data: %w", err) return fmt.Errorf("failed to update weather analytics for farm %s: %w", farmID, err)
} }
if cmdTag.RowsAffected() == 0 { r.logger.Debug("Updated farm weather analytics", "farm_id", farmID)
r.logger.Warn("No farm analytics record found to update weather data", "farm_id", farmID)
// Optionally, create the base record here if it should always exist
return domain.ErrNotFound // Or handle as appropriate
}
r.logger.Debug("Updated farm analytics weather data", "farm_id", farmID)
return nil return nil
} }
// UpdateFarmAnalyticsCropStats needs to query the croplands table for the farm // UpdateFarmAnalyticsCropStats needs to query the croplands table for the farm
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error { func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error {
var totalCount, growingCount int countQuery := `
// Query to count total and growing crops for the farm
query := `
SELECT SELECT
COUNT(*), COUNT(*),
COUNT(*) FILTER (WHERE status = 'growing') -- Case-insensitive comparison if needed: LOWER(status) = 'growing' COUNT(*) FILTER (WHERE lower(status) = 'growing')
FROM croplands FROM public.croplands
WHERE farm_id = $1;` WHERE farm_id = $1
`
err := r.conn.QueryRow(ctx, query, farmID).Scan(&totalCount, &growingCount) var totalCount, growingCount int
err := r.conn.QueryRow(ctx, countQuery, farmID).Scan(&totalCount, &growingCount)
if err != nil { if err != nil {
// Log error but don't fail the projection if stats can't be calculated temporarily if !errors.Is(err, pgx.ErrNoRows) {
r.logger.Error("Failed to calculate crop stats for analytics", "farm_id", farmID, "error", err) r.logger.Error("Error calculating crop counts", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to calculate crop stats: %w", err) return fmt.Errorf("failed to calculate crop stats for farm %s: %w", farmID, err)
}
} }
// Construct the JSONB object for crop_data
cropInfo := map[string]interface{}{
"totalCount": totalCount,
"growingCount": growingCount,
"lastUpdated": time.Now().UTC(), // Timestamp of this calculation
}
cropJSON, err := json.Marshal(cropInfo)
if err != nil {
r.logger.Error("Failed to marshal crop stats data", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to marshal crop stats: %w", err)
}
// Update the farm_analytics table
updateQuery := ` updateQuery := `
UPDATE farm_analytics UPDATE public.farm_analytics SET
SET crop_data = $1, crop_total_count = $2,
last_updated = $2 -- Also update the main last_updated timestamp crop_growing_count = $3,
WHERE farm_id = $3;` crop_last_updated = NOW(),
analytics_last_updated = NOW()
WHERE farm_id = $1`
cmdTag, err := r.conn.Exec(ctx, updateQuery, cropJSON, time.Now().UTC(), farmID) cmdTag, err := r.conn.Exec(ctx, updateQuery, farmID, totalCount, growingCount)
if err != nil { if err != nil {
r.logger.Error("Failed to update farm analytics crop stats", "farm_id", farmID, "error", err) r.logger.Error("Error updating farm crop stats", "farm_id", farmID, "error", err)
return fmt.Errorf("database update failed for crop stats: %w", err) return fmt.Errorf("failed to update crop stats for farm %s: %w", farmID, err)
} }
if cmdTag.RowsAffected() == 0 { if cmdTag.RowsAffected() == 0 {
r.logger.Warn("No farm analytics record found to update crop stats", "farm_id", farmID) r.logger.Warn("No farm analytics record found to update crop stats", "farm_id", farmID)
// Optionally, create the base record here // Optionally, create the base record here if it should always exist
} else { return r.CreateOrUpdateFarmBaseData(ctx, &domain.Farm{UUID: farmID /* Fetch other details */})
r.logger.Debug("Updated farm analytics crop stats", "farm_id", farmID, "total", totalCount, "growing", growingCount)
} }
r.logger.Debug("Updated farm crop stats", "farm_id", farmID, "total", totalCount, "growing", growingCount)
return nil return nil
} }
// UpdateFarmAnalyticsInventoryStats needs to query inventory_items // UpdateFarmAnalyticsInventoryStats needs to query inventory_items
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error { func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error {
var totalItems, lowStockCount int
var lastUpdated sql.NullTime
// Query to get inventory stats for the user owning the farm
// NOTE: This assumes inventory is linked by user_id, and we need the user_id for the farm owner.
// Step 1: Get Owner ID from farm_analytics table
var ownerID string
ownerQuery := `SELECT owner_id FROM farm_analytics WHERE farm_id = $1`
err := r.conn.QueryRow(ctx, ownerQuery, farmID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
r.logger.Warn("Cannot update inventory stats, farm analytics record not found", "farm_id", farmID)
return nil // Or return ErrNotFound if critical
}
r.logger.Error("Failed to get owner ID for inventory stats update", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to get owner ID: %w", err)
}
// Step 2: Query inventory based on owner ID
query := ` query := `
SELECT UPDATE public.farm_analytics SET
COUNT(*), -- inventory_total_items = (SELECT COUNT(*) FROM ... WHERE farm_id = $1), -- Example future logic
COUNT(*) FILTER (WHERE status_id = (SELECT id FROM inventory_status WHERE name = 'Low Stock')), -- Assumes 'Low Stock' status name -- inventory_low_stock_count = (SELECT COUNT(*) FROM ... WHERE farm_id = $1 AND status = 'low'), -- Example
MAX(updated_at) -- Get the latest update timestamp from inventory items inventory_last_updated = NOW(),
FROM inventory_items analytics_last_updated = NOW()
WHERE user_id = $1;` WHERE farm_id = $1`
err = r.conn.QueryRow(ctx, query, ownerID).Scan(&totalItems, &lowStockCount, &lastUpdated) cmdTag, err := r.conn.Exec(ctx, query, farmID)
if err != nil { if err != nil {
// Log error but don't fail the projection if stats can't be calculated temporarily r.logger.Error("Error touching inventory timestamp in farm analytics", "farm_id", farmID, "error", err)
r.logger.Error("Failed to calculate inventory stats for analytics", "farm_id", farmID, "owner_id", ownerID, "error", err) return fmt.Errorf("failed to update inventory stats timestamp for farm %s: %w", farmID, err)
return fmt.Errorf("failed to calculate inventory stats: %w", err)
}
// Construct the JSONB object for inventory_data
inventoryInfo := map[string]interface{}{
"totalItems": totalItems,
"lowStockCount": lowStockCount,
"lastUpdated": nil, // Initialize as nil
}
// Only set lastUpdated if the MAX(updated_at) query returned a valid time
if lastUpdated.Valid {
inventoryInfo["lastUpdated"] = lastUpdated.Time.UTC()
}
inventoryJSON, err := json.Marshal(inventoryInfo)
if err != nil {
r.logger.Error("Failed to marshal inventory stats data", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to marshal inventory stats: %w", err)
}
// Update the farm_analytics table
updateQuery := `
UPDATE farm_analytics
SET inventory_data = $1,
last_updated = $2 -- Also update the main last_updated timestamp
WHERE farm_id = $3;`
cmdTag, err := r.conn.Exec(ctx, updateQuery, inventoryJSON, time.Now().UTC(), farmID)
if err != nil {
r.logger.Error("Failed to update farm analytics inventory stats", "farm_id", farmID, "error", err)
return fmt.Errorf("database update failed for inventory stats: %w", err)
} }
if cmdTag.RowsAffected() == 0 { if cmdTag.RowsAffected() == 0 {
r.logger.Warn("No farm analytics record found to update inventory stats", "farm_id", farmID) r.logger.Warn("No farm analytics record found to update inventory timestamp", "farm_id", farmID)
} else {
r.logger.Debug("Updated farm analytics inventory stats", "farm_id", farmID, "total", totalItems, "lowStock", lowStockCount)
} }
r.logger.Debug("Updated farm inventory timestamp", "farm_id", farmID)
return nil return nil
} }
@ -432,20 +374,18 @@ func (r *postgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Contex
func (r *postgresFarmAnalyticsRepository) UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error { func (r *postgresFarmAnalyticsRepository) UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error {
query := ` query := `
UPDATE farm_analytics UPDATE public.farm_analytics SET
SET overall_status = $1, overall_status = $2,
last_updated = $2 analytics_last_updated = NOW()
WHERE farm_id = $3;` WHERE farm_id = $1`
cmdTag, err := r.conn.Exec(ctx, query, status, time.Now().UTC(), farmID) cmdTag, err := r.conn.Exec(ctx, query, farmID, status)
if err != nil { if err != nil {
r.logger.Error("Failed to update farm overall status", "farm_id", farmID, "status", status, "error", err) r.logger.Error("Error updating farm overall status", "farm_id", farmID, "status", status, "error", err)
return fmt.Errorf("database update failed for overall status: %w", err) return fmt.Errorf("failed to update overall status for farm %s: %w", farmID, err)
} }
if cmdTag.RowsAffected() == 0 { if cmdTag.RowsAffected() == 0 {
r.logger.Warn("No farm analytics record found to update overall status", "farm_id", farmID) r.logger.Warn("No farm analytics record found to update overall status", "farm_id", farmID)
// Optionally, create the base record here if needed
return domain.ErrNotFound
} }
r.logger.Debug("Updated farm overall status", "farm_id", farmID, "status", status) r.logger.Debug("Updated farm overall status", "farm_id", farmID, "status", status)
return nil return nil

View File

@ -268,15 +268,15 @@ func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *
} }
payload := map[string]interface{}{ payload := map[string]interface{}{
"item_id": item.ID, "id": item.ID,
"user_id": item.UserID, // Include user ID for potential farm lookup in projection "userId": item.UserID, // Include user ID for potential farm lookup in projection
"name": item.Name, "name": item.Name,
"category_id": item.CategoryID, "categoryId": item.CategoryID,
"quantity": item.Quantity, "quantity": item.Quantity,
"unit_id": item.UnitID, "unitId": item.UnitID,
"status_id": item.StatusID, "statusId": item.StatusID,
"date_added": item.DateAdded, "dateAdded": item.DateAdded,
"updated_at": item.UpdatedAt, "updatedAt": item.UpdatedAt,
// NO farm_id easily available here without extra lookup // NO farm_id easily available here without extra lookup
} }

View File

@ -51,6 +51,15 @@ func (p *postgresPlantRepository) GetByUUID(ctx context.Context, uuid string) (d
return plants[0], nil return plants[0], nil
} }
func (p *postgresPlantRepository) GetByName(ctx context.Context, name string) (domain.Plant, error) {
query := `SELECT * FROM plants WHERE name = $1`
plants, err := p.fetch(ctx, query, name)
if err != nil || len(plants) == 0 {
return domain.Plant{}, domain.ErrNotFound
}
return plants[0], nil
}
func (p *postgresPlantRepository) GetAll(ctx context.Context) ([]domain.Plant, error) { func (p *postgresPlantRepository) GetAll(ctx context.Context) ([]domain.Plant, error) {
query := `SELECT * FROM plants` query := `SELECT * FROM plants`
return p.fetch(ctx, query) return p.fetch(ctx, query)

View File

@ -7,41 +7,31 @@ import (
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
) )
// AnalyticsService provides methods for calculating or deriving analytics data.
// For now, it contains dummy implementations.
type AnalyticsService struct { type AnalyticsService struct {
// Add dependencies like repositories if needed for real logic later
} }
// NewAnalyticsService creates a new AnalyticsService.
func NewAnalyticsService() *AnalyticsService { func NewAnalyticsService() *AnalyticsService {
return &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 { func (s *AnalyticsService) CalculatePlantHealth(status string, growthStage string) string {
// Simple dummy logic
switch status { switch status {
case "Problem", "Diseased", "Infested": case "Problem", "Diseased", "Infested":
return "warning" return "warning"
case "Fallow", "Harvested": case "Fallow", "Harvested":
return "n/a" // Or maybe 'good' if fallow is considered healthy state return "n/a"
default: default:
// Slightly randomize for demo purposes // 20% chance of warning even if status is 'growing'
if rand.Intn(10) < 2 { // 20% chance of warning even if status is 'growing' if rand.Intn(10) < 2 {
return "warning" return "warning"
} }
return "good" 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) { func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated time.Time) (action *string, dueDate *time.Time) {
// Default action
nextActionStr := "Monitor crop health" nextActionStr := "Monitor crop health"
nextDueDate := time.Now().Add(24 * time.Hour) // Check tomorrow nextDueDate := time.Now().Add(24 * time.Hour)
switch growthStage { switch growthStage {
case "Planned", "Planting": case "Planned", "Planting":
@ -58,30 +48,27 @@ func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated tim
nextDueDate = time.Now().Add(48 * time.Hour) nextDueDate = time.Now().Add(48 * time.Hour)
case "Fruiting", "Ripening": case "Fruiting", "Ripening":
nextActionStr = "Monitor fruit development and prepare for harvest" nextActionStr = "Monitor fruit development and prepare for harvest"
nextDueDate = time.Now().Add(7 * 24 * time.Hour) // Check in a week nextDueDate = time.Now().Add(7 * 24 * time.Hour)
case "Harvesting": case "Harvesting":
nextActionStr = "Proceed with harvest" nextActionStr = "Proceed with harvest"
nextDueDate = time.Now().Add(24 * time.Hour) nextDueDate = time.Now().Add(24 * time.Hour)
} }
// Only return if the suggestion is "newer" than the last update to avoid constant same suggestion // Only suggest if due date is >1hr after last update
// This is basic logic, real implementation would be more complex if nextDueDate.After(lastUpdated.Add(1 * time.Hour)) {
if nextDueDate.After(lastUpdated.Add(1 * time.Hour)) { // Only suggest if due date is >1hr after last update
return &nextActionStr, &nextDueDate return &nextActionStr, &nextDueDate
} }
return nil, nil // No immediate action needed or suggestion is old return nil, nil
} }
// 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 { func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct {
Nitrogen *float64 `json:"nitrogen,omitempty"` Nitrogen *float64 `json:"nitrogen,omitempty"`
Phosphorus *float64 `json:"phosphorus,omitempty"` Phosphorus *float64 `json:"phosphorus,omitempty"`
Potassium *float64 `json:"potassium,omitempty"` Potassium *float64 `json:"potassium,omitempty"`
} { } {
// Return dummy data or nil if unavailable // 70% chance of having dummy data
if rand.Intn(10) < 7 { // 70% chance of having dummy data if rand.Intn(10) < 7 {
n := float64(50 + rand.Intn(40)) // 50-89 n := float64(50 + rand.Intn(40)) // 50-89
p := float64(40 + rand.Intn(40)) // 40-79 p := float64(40 + rand.Intn(40)) // 40-79
k := float64(45 + rand.Intn(40)) // 45-84 k := float64(45 + rand.Intn(40)) // 45-84
@ -95,26 +82,20 @@ func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct {
Potassium: &k, Potassium: &k,
} }
} }
return nil // Simulate data not available return nil
} }
// 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) { 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 temp, humidity, wind, rain, sunlight, soilMoisture = nil, nil, nil, nil, nil, nil
// Try to get from FarmAnalytics
if farmAnalytics != nil && farmAnalytics.Weather != nil { if farmAnalytics != nil && farmAnalytics.Weather != nil {
temp = farmAnalytics.Weather.TempCelsius temp = farmAnalytics.Weather.TempCelsius
humidity = farmAnalytics.Weather.Humidity humidity = farmAnalytics.Weather.Humidity
wind = farmAnalytics.Weather.WindSpeed wind = farmAnalytics.Weather.WindSpeed
rain = farmAnalytics.Weather.RainVolume1h 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) // Provide dummy values only if data is missing
if temp == nil { if temp == nil {
t := float64(18 + rand.Intn(15)) // 18-32 C t := float64(18 + rand.Intn(15)) // 18-32 C
temp = &t temp = &t
@ -128,7 +109,6 @@ func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalyt
wind = &w wind = &w
} }
if rain == nil { if rain == nil {
// Simulate less frequent rain
r := 0.0 r := 0.0
if rand.Intn(10) < 2 { // 20% chance of rain if rand.Intn(10) < 2 { // 20% chance of rain
r = float64(rand.Intn(5)) // 0-4 mm r = float64(rand.Intn(5)) // 0-4 mm
@ -144,5 +124,5 @@ func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalyt
soilMoisture = &sm soilMoisture = &sm
} }
return // Named return values return
} }

View File

@ -1,4 +1,3 @@
// backend/internal/services/weather/openweathermap_fetcher.go
package weather package weather
import ( import (
@ -14,45 +13,57 @@ import (
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
) )
const openWeatherMapOneCallAPIURL = "https://api.openweathermap.org/data/3.0/onecall" const openWeatherMapCurrentAPIURL = "https://api.openweathermap.org/data/2.5/weather"
type openWeatherMapOneCallResponse struct { type openWeatherMapCurrentResponse struct {
Lat float64 `json:"lat"` Coord struct {
Lon float64 `json:"lon"` Lon float64 `json:"lon"`
Timezone string `json:"timezone"` Lat float64 `json:"lat"`
TimezoneOffset int `json:"timezone_offset"` } `json:"coord"`
Current *struct {
Dt int64 `json:"dt"` // Current time, Unix, UTC
Sunrise int64 `json:"sunrise"`
Sunset int64 `json:"sunset"`
Temp float64 `json:"temp"` // Kelvin by default, 'units=metric' for Celsius
FeelsLike float64 `json:"feels_like"` // Kelvin by default
Pressure int `json:"pressure"` // hPa
Humidity int `json:"humidity"` // %
DewPoint float64 `json:"dew_point"`
Uvi float64 `json:"uvi"`
Clouds int `json:"clouds"` // %
Visibility int `json:"visibility"` // meters
WindSpeed float64 `json:"wind_speed"` // meter/sec by default
WindDeg int `json:"wind_deg"`
WindGust float64 `json:"wind_gust,omitempty"`
Rain *struct {
OneH float64 `json:"1h"` // Rain volume for the last 1 hour, mm
} `json:"rain,omitempty"`
Snow *struct {
OneH float64 `json:"1h"` // Snow volume for the last 1 hour, mm
} `json:"snow,omitempty"`
Weather []struct { Weather []struct {
ID int `json:"id"` ID int `json:"id"`
Main string `json:"main"` Main string `json:"main"`
Description string `json:"description"` Description string `json:"description"`
Icon string `json:"icon"` Icon string `json:"icon"`
} `json:"weather"` } `json:"weather"`
} `json:"current,omitempty"` Base string `json:"base"`
// Minutely []... Main *struct {
// Hourly []... Temp float64 `json:"temp"`
// Daily []... FeelsLike float64 `json:"feels_like"`
// Alerts []... TempMin float64 `json:"temp_min"`
TempMax float64 `json:"temp_max"`
Pressure int `json:"pressure"`
Humidity int `json:"humidity"`
SeaLevel int `json:"sea_level,omitempty"`
GrndLevel int `json:"grnd_level,omitempty"`
} `json:"main"`
Visibility int `json:"visibility"`
Wind *struct {
Speed float64 `json:"speed"`
Deg int `json:"deg"`
Gust float64 `json:"gust,omitempty"`
} `json:"wind"`
Rain *struct {
OneH float64 `json:"1h"`
} `json:"rain,omitempty"`
Snow *struct {
OneH float64 `json:"1h"`
} `json:"snow,omitempty"`
Clouds *struct {
All int `json:"all"`
} `json:"clouds"`
Dt int64 `json:"dt"`
Sys *struct {
Type int `json:"type,omitempty"`
ID int `json:"id,omitempty"`
Country string `json:"country"`
Sunrise int64 `json:"sunrise"`
Sunset int64 `json:"sunset"`
} `json:"sys"`
Timezone int `json:"timezone"`
ID int `json:"id"`
Name string `json:"name"`
Cod int `json:"cod"`
} }
type OpenWeatherMapFetcher struct { type OpenWeatherMapFetcher struct {
@ -80,11 +91,10 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
queryParams.Set("lat", fmt.Sprintf("%.4f", lat)) queryParams.Set("lat", fmt.Sprintf("%.4f", lat))
queryParams.Set("lon", fmt.Sprintf("%.4f", lon)) queryParams.Set("lon", fmt.Sprintf("%.4f", lon))
queryParams.Set("appid", f.apiKey) queryParams.Set("appid", f.apiKey)
queryParams.Set("units", "metric") // Request Celsius and m/s queryParams.Set("units", "metric")
queryParams.Set("exclude", "minutely,hourly,daily,alerts") // Exclude parts we don't need now
fullURL := fmt.Sprintf("%s?%s", openWeatherMapOneCallAPIURL, queryParams.Encode()) fullURL := fmt.Sprintf("%s?%s", openWeatherMapCurrentAPIURL, queryParams.Encode())
f.logger.Debug("Fetching weather from OpenWeatherMap OneCall API", "url", fullURL) f.logger.Debug("Fetching weather from OpenWeatherMap Current API", "url", fullURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil { if err != nil {
@ -100,7 +110,6 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// TODO: Read resp.Body to get error message from OpenWeatherMap
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
f.logger.Error("OpenWeatherMap API returned non-OK status", f.logger.Error("OpenWeatherMap API returned non-OK status",
"url", fullURL, "url", fullURL,
@ -109,46 +118,60 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
return nil, fmt.Errorf("weather API request failed with status: %s", resp.Status) return nil, fmt.Errorf("weather API request failed with status: %s", resp.Status)
} }
var owmResp openWeatherMapOneCallResponse var owmResp openWeatherMapCurrentResponse
if err := json.NewDecoder(resp.Body).Decode(&owmResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&owmResp); err != nil {
f.logger.Error("Failed to decode OpenWeatherMap OneCall response", "error", err) f.logger.Error("Failed to decode OpenWeatherMap Current response", "error", err)
return nil, fmt.Errorf("failed to decode weather response: %w", err) return nil, fmt.Errorf("failed to decode weather response: %w", err)
} }
if owmResp.Current == nil { // --- Data Mapping from openWeatherMapCurrentResponse to domain.WeatherData ---
f.logger.Warn("OpenWeatherMap OneCall response missing 'current' weather data", "lat", lat, "lon", lon)
return nil, fmt.Errorf("current weather data not found in API response")
}
current := owmResp.Current
if len(current.Weather) == 0 { if owmResp.Main == nil {
f.logger.Warn("OpenWeatherMap response missing weather description details", "lat", lat, "lon", lon) f.logger.Error("OpenWeatherMap Current response missing 'main' data block", "lat", lat, "lon", lon)
return nil, fmt.Errorf("weather data description not found in response") return nil, fmt.Errorf("main weather data block not found in API response")
} }
// Create domain object using pointers for optional fields weatherData := &domain.WeatherData{}
weatherData := &domain.WeatherData{} // Initialize empty struct first
// Assign values using pointers, checking for nil where appropriate weatherData.TempCelsius = &owmResp.Main.Temp
weatherData.TempCelsius = &current.Temp humidityFloat := float64(owmResp.Main.Humidity)
humidityFloat := float64(current.Humidity)
weatherData.Humidity = &humidityFloat weatherData.Humidity = &humidityFloat
weatherData.Description = &current.Weather[0].Description
weatherData.Icon = &current.Weather[0].Icon if len(owmResp.Weather) > 0 {
weatherData.WindSpeed = &current.WindSpeed weatherData.Description = &owmResp.Weather[0].Description
if current.Rain != nil { weatherData.Icon = &owmResp.Weather[0].Icon
weatherData.RainVolume1h = &current.Rain.OneH } else {
f.logger.Warn("OpenWeatherMap Current response missing 'weather' description details", "lat", lat, "lon", lon)
} }
observedTime := time.Unix(current.Dt, 0).UTC()
if owmResp.Wind != nil {
weatherData.WindSpeed = &owmResp.Wind.Speed
} else {
f.logger.Warn("OpenWeatherMap Current response missing 'wind' data block", "lat", lat, "lon", lon)
}
if owmResp.Rain != nil {
weatherData.RainVolume1h = &owmResp.Rain.OneH
}
observedTime := time.Unix(owmResp.Dt, 0).UTC()
weatherData.ObservedAt = &observedTime weatherData.ObservedAt = &observedTime
now := time.Now().UTC() now := time.Now().UTC()
weatherData.WeatherLastUpdated = &now weatherData.WeatherLastUpdated = &now
f.logger.Debug("Successfully fetched weather data", logTemp := "nil"
if weatherData.TempCelsius != nil {
logTemp = fmt.Sprintf("%.2f", *weatherData.TempCelsius)
}
logDesc := "nil"
if weatherData.Description != nil {
logDesc = *weatherData.Description
}
f.logger.Debug("Successfully fetched and mapped weather data",
"lat", lat, "lat", lat,
"lon", lon, "lon", lon,
"temp", *weatherData.TempCelsius, "temp", logTemp,
"description", *weatherData.Description) "description", logDesc)
return weatherData, nil return weatherData, nil
} }

View File

@ -3,6 +3,7 @@ package workers
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"sync" "sync"
"time" "time"
@ -27,13 +28,23 @@ func NewWeatherUpdater(
eventPublisher domain.EventPublisher, eventPublisher domain.EventPublisher,
logger *slog.Logger, logger *slog.Logger,
fetchInterval time.Duration, fetchInterval time.Duration,
) *WeatherUpdater { ) (*WeatherUpdater, error) {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
if fetchInterval <= 0 { if fetchInterval <= 0 {
fetchInterval = 15 * time.Minute fetchInterval = 60 * time.Minute
} }
if farmRepo == nil {
return nil, fmt.Errorf("farmRepo cannot be nil")
}
if weatherFetcher == nil {
return nil, fmt.Errorf("weatherFetcher cannot be nil")
}
if eventPublisher == nil {
return nil, fmt.Errorf("eventPublisher cannot be nil")
}
return &WeatherUpdater{ return &WeatherUpdater{
farmRepo: farmRepo, farmRepo: farmRepo,
weatherFetcher: weatherFetcher, weatherFetcher: weatherFetcher,
@ -41,7 +52,7 @@ func NewWeatherUpdater(
logger: logger, logger: logger,
fetchInterval: fetchInterval, fetchInterval: fetchInterval,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
} }, nil
} }
func (w *WeatherUpdater) Start(ctx context.Context) { func (w *WeatherUpdater) Start(ctx context.Context) {
@ -75,20 +86,22 @@ func (w *WeatherUpdater) Start(ctx context.Context) {
func (w *WeatherUpdater) Stop() { func (w *WeatherUpdater) Stop() {
w.logger.Info("Attempting to stop Weather Updater worker...") w.logger.Info("Attempting to stop Weather Updater worker...")
select {
case <-w.stopChan:
default:
close(w.stopChan) close(w.stopChan)
w.wg.Wait() }
w.wg.Wait() // Wait for the goroutine to finish
w.logger.Info("Weather Updater worker stopped") w.logger.Info("Weather Updater worker stopped")
} }
func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) { func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
// Use a background context for the repository call if the main context might cancel prematurely repoCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Use separate context for DB query
// repoCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Example timeout defer cancel()
// defer cancel()
// TODO: Need a GetAllFarms method in the FarmRepository or a way to efficiently get all farm locations. farms, err := w.farmRepo.GetAll(repoCtx) // <-- Changed method call
farms, err := w.farmRepo.GetByOwnerID(ctx, "") // !! REPLACE with a proper GetAll method !!
if err != nil { if err != nil {
w.logger.Error("Failed to get farms for weather update", "error", err) w.logger.Error("Failed to get all farms for weather update", "error", err)
return return
} }
if len(farms) == 0 { if len(farms) == 0 {
@ -96,12 +109,15 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
return return
} }
w.logger.Info("Found farms for weather update", "count", len(farms)) w.logger.Info("Processing farms for weather update", "count", len(farms))
var fetchWg sync.WaitGroup var fetchWg sync.WaitGroup
fetchCtx, cancelFetches := context.WithCancel(ctx) fetchCtx, cancelFetches := context.WithCancel(ctx)
defer cancelFetches() defer cancelFetches()
concurrencyLimit := 5
sem := make(chan struct{}, concurrencyLimit)
for _, farm := range farms { for _, farm := range farms {
if farm.Lat == 0 && farm.Lon == 0 { if farm.Lat == 0 && farm.Lon == 0 {
w.logger.Warn("Skipping farm with zero coordinates", "farm_id", farm.UUID, "farm_name", farm.Name) w.logger.Warn("Skipping farm with zero coordinates", "farm_id", farm.UUID, "farm_name", farm.Name)
@ -109,10 +125,14 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
} }
fetchWg.Add(1) fetchWg.Add(1)
sem <- struct{}{}
go func(f domain.Farm) { go func(f domain.Farm) {
defer fetchWg.Done() defer fetchWg.Done()
defer func() { <-sem }()
select { select {
case <-fetchCtx.Done(): case <-fetchCtx.Done():
w.logger.Info("Weather fetch cancelled for farm", "farm_id", f.UUID, "reason", fetchCtx.Err())
return return
default: default:
w.fetchAndPublishWeather(fetchCtx, f) w.fetchAndPublishWeather(fetchCtx, f)
@ -121,7 +141,7 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
} }
fetchWg.Wait() fetchWg.Wait()
w.logger.Info("Finished weather fetch cycle for farms", "count", len(farms)) w.logger.Debug("Finished weather fetch cycle for farms", "count", len(farms)) // Use Debug for cycle completion
} }
func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain.Farm) { func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain.Farm) {
@ -139,14 +159,14 @@ func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain
"farm_id": farm.UUID, "farm_id": farm.UUID,
"lat": farm.Lat, "lat": farm.Lat,
"lon": farm.Lon, "lon": farm.Lon,
"temp_celsius": weatherData.TempCelsius, "tempCelsius": weatherData.TempCelsius,
"humidity": weatherData.Humidity, "humidity": weatherData.Humidity,
"description": weatherData.Description, "description": weatherData.Description,
"icon": weatherData.Icon, "icon": weatherData.Icon,
"wind_speed": weatherData.WindSpeed, "windSpeed": weatherData.WindSpeed,
"rain_volume_1h": weatherData.RainVolume1h, "rainVolume1h": weatherData.RainVolume1h,
"observed_at": weatherData.ObservedAt, "observedAt": weatherData.ObservedAt,
"weather_last_updated": weatherData.WeatherLastUpdated, "weatherLastUpdated": weatherData.WeatherLastUpdated,
} }
event := domain.Event{ event := domain.Event{