ForFarm/backend/internal/repository/postgres_farm_analytics.go

446 lines
16 KiB
Go

// backend/internal/repository/postgres_farm_analytics.go
package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"github.com/forfarm/backend/internal/domain"
"github.com/jackc/pgx/v5"
)
type postgresFarmAnalyticsRepository struct {
conn Connection
logger *slog.Logger
}
func NewPostgresFarmAnalyticsRepository(conn Connection, logger *slog.Logger) domain.AnalyticsRepository {
if logger == nil {
logger = slog.Default()
}
return &postgresFarmAnalyticsRepository{
conn: conn,
logger: logger,
}
}
func (r *postgresFarmAnalyticsRepository) GetFarmAnalytics(ctx context.Context, farmID string) (*domain.FarmAnalytics, error) {
query := `
SELECT
farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude,
weather_temp_celsius, weather_humidity, weather_description, weather_icon,
weather_wind_speed, weather_rain_1h, weather_observed_at, weather_last_updated,
inventory_total_items, inventory_low_stock_count, inventory_last_updated,
crop_total_count, crop_growing_count, crop_last_updated,
overall_status, analytics_last_updated
FROM public.farm_analytics
WHERE farm_id = $1`
var analytics domain.FarmAnalytics
var farmType sql.NullString
var totalSize sql.NullString
var weatherJSON, inventoryJSON, cropJSON []byte // Use []byte for JSONB
var overallStatus sql.NullString
err := r.conn.QueryRow(ctx, query, farmID).Scan(
&analytics.FarmID,
&analytics.FarmName,
&analytics.OwnerID,
&farmType,
&totalSize,
&analytics.Latitude, // Scan directly into the struct fields
&analytics.Longitude,
&weatherJSON,
&inventoryJSON,
&cropJSON,
&overallStatus,
&analytics.AnalyticsLastUpdated,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
r.logger.Warn("Farm analytics data not found", "farm_id", farmID)
return nil, domain.ErrNotFound
}
r.logger.Error("Failed to query farm analytics", "farm_id", farmID, "error", err)
return nil, fmt.Errorf("database query failed for farm analytics: %w", err)
}
// Handle nullable fields
if farmType.Valid {
analytics.FarmType = &farmType.String
}
if totalSize.Valid {
analytics.TotalSize = &totalSize.String
}
if overallStatus.Valid {
analytics.OverallStatus = &overallStatus.String
}
// Unmarshal JSONB data
if weatherJSON != nil {
if err := json.Unmarshal(weatherJSON, &analytics.Weather); err != nil {
r.logger.Warn("Failed to unmarshal weather data from farm analytics", "farm_id", farmID, "error", err)
// Continue, but log the issue
}
}
if inventoryJSON != nil {
if err := json.Unmarshal(inventoryJSON, &analytics.InventoryInfo); err != nil {
r.logger.Warn("Failed to unmarshal inventory data from farm analytics", "farm_id", farmID, "error", err)
}
}
if cropJSON != nil {
if err := json.Unmarshal(cropJSON, &analytics.CropInfo); err != nil {
r.logger.Warn("Failed to unmarshal crop data from farm analytics", "farm_id", farmID, "error", err)
}
}
r.logger.Debug("Successfully retrieved farm analytics", "farm_id", farmID)
return &analytics, nil
}
// --- Calculation Helper ---
// calculateGrowthProgress calculates the percentage completion based on planting date and maturity days.
func calculateGrowthProgress(plantedAt time.Time, daysToMaturity *int) int {
if daysToMaturity == nil || *daysToMaturity <= 0 {
return 0 // Cannot calculate if maturity days are unknown or zero
}
if plantedAt.IsZero() {
return 0 // Cannot calculate if planting date is unknown
}
today := time.Now()
daysElapsed := today.Sub(plantedAt).Hours() / 24
progress := (daysElapsed / float64(*daysToMaturity)) * 100
// Clamp progress between 0 and 100
if progress < 0 {
return 0
}
if progress > 100 {
return 100
}
return int(progress)
}
// --- GetCropAnalytics ---
// GetCropAnalytics retrieves and calculates analytics data for a specific crop.
func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, cropID string) (*domain.CropAnalytics, error) {
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
FROM
croplands c
JOIN
plants p ON c.plant_id = p.uuid
WHERE
c.uuid = $1
`
var analytics domain.CropAnalytics
var plantName string
var variety sql.NullString
var daysToMaturity sql.NullInt32
var plantedAt time.Time // Use cropland created_at as planted date proxy
// Use r.conn here
err := r.conn.QueryRow(ctx, query, cropID).Scan(
&analytics.CropID,
&analytics.CropName,
&analytics.FarmID,
&analytics.CurrentStatus,
&analytics.GrowthStage,
&analytics.LandSize,
&analytics.LastUpdated,
&plantName,
&variety,
&daysToMaturity,
&plantedAt,
)
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)
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)
}
analytics.PlantName = plantName
if variety.Valid {
analytics.Variety = &variety.String
}
// Calculate Growth Progress
var maturityDaysPtr *int
if daysToMaturity.Valid {
maturityInt := int(daysToMaturity.Int32)
maturityDaysPtr = &maturityInt
}
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)
}
// 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
r.logger.Debug("Successfully retrieved crop analytics", "crop_id", cropID, "farm_id", analytics.FarmID)
return &analytics, nil
}
// --- Implement other AnalyticsRepository methods ---
func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error {
query := `
INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, lat, lon, last_updated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (farm_id) DO UPDATE
SET farm_name = EXCLUDED.farm_name,
owner_id = EXCLUDED.owner_id,
farm_type = EXCLUDED.farm_type,
total_size = EXCLUDED.total_size,
lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
last_updated = EXCLUDED.last_updated;`
_, err := r.conn.Exec(ctx, query,
farm.UUID,
farm.Name,
farm.OwnerID,
farm.FarmType, // Handle potential empty string vs null if needed
farm.TotalSize, // Handle potential empty string vs null if needed
farm.Lat,
farm.Lon,
time.Now().UTC(), // Update timestamp on change
)
if err != nil {
r.logger.Error("Failed to create/update farm base analytics data", "farm_id", farm.UUID, "error", err)
return fmt.Errorf("failed to upsert farm base data: %w", err)
}
r.logger.Debug("Upserted farm base analytics data", "farm_id", farm.UUID)
return nil
}
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error {
if weatherData == nil {
return fmt.Errorf("weather data cannot be nil")
}
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 := `
UPDATE farm_analytics
SET weather_data = $1,
last_updated = $2
WHERE farm_id = $3;`
cmdTag, err := r.conn.Exec(ctx, query, weatherJSON, time.Now().UTC(), farmID)
if err != nil {
r.logger.Error("Failed to update farm analytics weather data", "farm_id", farmID, "error", err)
return fmt.Errorf("database update failed for weather data: %w", err)
}
if cmdTag.RowsAffected() == 0 {
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
}
// UpdateFarmAnalyticsCropStats needs to query the croplands table for the farm
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error {
var totalCount, growingCount int
// Query to count total and growing crops for the farm
query := `
SELECT
COUNT(*),
COUNT(*) FILTER (WHERE status = 'growing') -- Case-insensitive comparison if needed: LOWER(status) = 'growing'
FROM croplands
WHERE farm_id = $1;`
err := r.conn.QueryRow(ctx, query, farmID).Scan(&totalCount, &growingCount)
if err != nil {
// Log error but don't fail the projection if stats can't be calculated temporarily
r.logger.Error("Failed to calculate crop stats for analytics", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to calculate crop stats: %w", 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 := `
UPDATE farm_analytics
SET crop_data = $1,
last_updated = $2 -- Also update the main last_updated timestamp
WHERE farm_id = $3;`
cmdTag, err := r.conn.Exec(ctx, updateQuery, cropJSON, time.Now().UTC(), farmID)
if err != nil {
r.logger.Error("Failed to update farm analytics crop stats", "farm_id", farmID, "error", err)
return fmt.Errorf("database update failed for crop stats: %w", err)
}
if cmdTag.RowsAffected() == 0 {
r.logger.Warn("No farm analytics record found to update crop stats", "farm_id", farmID)
// Optionally, create the base record here
} else {
r.logger.Debug("Updated farm analytics crop stats", "farm_id", farmID, "total", totalCount, "growing", growingCount)
}
return nil
}
// UpdateFarmAnalyticsInventoryStats needs to query inventory_items
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 := `
SELECT
COUNT(*),
COUNT(*) FILTER (WHERE status_id = (SELECT id FROM inventory_status WHERE name = 'Low Stock')), -- Assumes 'Low Stock' status name
MAX(updated_at) -- Get the latest update timestamp from inventory items
FROM inventory_items
WHERE user_id = $1;`
err = r.conn.QueryRow(ctx, query, ownerID).Scan(&totalItems, &lowStockCount, &lastUpdated)
if err != nil {
// Log error but don't fail the projection if stats can't be calculated temporarily
r.logger.Error("Failed to calculate inventory stats for analytics", "farm_id", farmID, "owner_id", ownerID, "error", 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 {
r.logger.Warn("No farm analytics record found to update inventory stats", "farm_id", farmID)
} else {
r.logger.Debug("Updated farm analytics inventory stats", "farm_id", farmID, "total", totalItems, "lowStock", lowStockCount)
}
return nil
}
func (r *postgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Context, farmID string) error {
query := `DELETE FROM farm_analytics WHERE farm_id = $1;`
cmdTag, err := r.conn.Exec(ctx, query, farmID)
if err != nil {
r.logger.Error("Failed to delete farm analytics data", "farm_id", farmID, "error", err)
return fmt.Errorf("database delete failed for farm analytics: %w", err)
}
if cmdTag.RowsAffected() == 0 {
r.logger.Warn("No farm analytics record found to delete", "farm_id", farmID)
// Return ErrNotFound if it's important to know it wasn't there
return domain.ErrNotFound
}
r.logger.Info("Deleted farm analytics data", "farm_id", farmID)
return nil
}
func (r *postgresFarmAnalyticsRepository) UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error {
query := `
UPDATE farm_analytics
SET overall_status = $1,
last_updated = $2
WHERE farm_id = $3;`
cmdTag, err := r.conn.Exec(ctx, query, status, time.Now().UTC(), farmID)
if err != nil {
r.logger.Error("Failed to update farm overall status", "farm_id", farmID, "status", status, "error", err)
return fmt.Errorf("database update failed for overall status: %w", err)
}
if cmdTag.RowsAffected() == 0 {
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)
return nil
}