mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04:08 +01:00
453 lines
16 KiB
Go
453 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/forfarm/backend/internal/services"
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
type postgresFarmAnalyticsRepository struct {
|
|
conn Connection
|
|
logger *slog.Logger
|
|
analyticsService *services.AnalyticsService
|
|
}
|
|
|
|
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,
|
|
analyticsService: analyticsService,
|
|
}
|
|
}
|
|
|
|
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 ---
|
|
|
|
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 -- Planted date proxy
|
|
FROM
|
|
croplands c
|
|
JOIN
|
|
plants p ON c.plant_id = p.uuid
|
|
WHERE
|
|
c.uuid = $1
|
|
`
|
|
|
|
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
|
|
var croplandLastUpdated time.Time // Capture cropland specific update time
|
|
|
|
err := r.conn.QueryRow(ctx, query, cropID).Scan(
|
|
&analytics.CropID,
|
|
&analytics.CropName,
|
|
&analytics.FarmID,
|
|
&analytics.CurrentStatus,
|
|
&analytics.GrowthStage,
|
|
&analytics.LandSize,
|
|
&croplandLastUpdated, // Use this for action suggestion timing
|
|
&plantName,
|
|
&variety,
|
|
&daysToMaturity,
|
|
&plantedAt,
|
|
)
|
|
|
|
if err != nil {
|
|
// ... (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 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/Fetch derived fields using the service ---
|
|
|
|
// Growth Progress
|
|
var maturityDaysPtr *int
|
|
if daysToMaturity.Valid {
|
|
maturityInt := int(daysToMaturity.Int32)
|
|
maturityDaysPtr = &maturityInt
|
|
}
|
|
analytics.GrowthProgress = calculateGrowthProgress(plantedAt, maturityDaysPtr)
|
|
|
|
// 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)
|
|
|
|
// Plant Health (Dummy)
|
|
health := r.analyticsService.CalculatePlantHealth(analytics.CurrentStatus, analytics.GrowthStage)
|
|
analytics.PlantHealth = &health
|
|
|
|
// 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
|
|
}
|
|
|
|
// --- 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
|
|
}
|