diff --git a/backend/.air.toml b/backend/.air.toml index 026f712..bd22597 100644 --- a/backend/.air.toml +++ b/backend/.air.toml @@ -2,8 +2,8 @@ root = "." tmp_dir = "tmp" [build] -cmd = "go build -o ./tmp/api ./cmd/forfarm" -bin = "./tmp/api" +cmd = "go build -o ./tmp/api.exe ./cmd/forfarm" +bin = "./tmp/api.exe" args_bin = ["api"] include_ext = ["go", "tpl", "tmpl", "html"] exclude_dir = ["assets", "tmp", "vendor"] diff --git a/backend/go.mod b/backend/go.mod index 529ff3f..38406e5 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,8 +12,9 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 github.com/joho/godotenv v1.5.1 - github.com/lib/pq v1.10.9 + github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pressly/goose/v3 v3.24.1 + github.com/rabbitmq/amqp091-go v1.10.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.19.0 golang.org/x/crypto v0.31.0 diff --git a/backend/go.sum b/backend/go.sum index aa7bee7..1c61c53 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -48,8 +48,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= -github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -60,6 +58,8 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -67,6 +67,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY= github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= +github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= @@ -99,6 +101,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= diff --git a/backend/internal/api/analytic.go b/backend/internal/api/analytic.go new file mode 100644 index 0000000..1e3c867 --- /dev/null +++ b/backend/internal/api/analytic.go @@ -0,0 +1,134 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/domain" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +func (a *api) registerAnalyticsRoutes(_ chi.Router, api huma.API) { + tags := []string{"analytics"} + prefix := "/analytics" + + huma.Register(api, huma.Operation{ + OperationID: "getFarmAnalytics", + Method: http.MethodGet, + Path: prefix + "/farm/{farmId}", // Changed path param name + Tags: tags, + Summary: "Get aggregated analytics data for a specific farm", + Description: "Retrieves various analytics metrics for a farm, requiring user ownership.", + }, a.getFarmAnalyticsHandler) + + // New endpoint for Crop Analytics + huma.Register(api, huma.Operation{ + OperationID: "getCropAnalytics", + Method: http.MethodGet, + Path: prefix + "/crop/{cropId}", // Changed path param name + Tags: tags, + Summary: "Get analytics data for a specific crop", + Description: "Retrieves analytics metrics for a specific crop/cropland, requiring user ownership of the parent farm.", + }, a.getCropAnalyticsHandler) +} + +type GetFarmAnalyticsInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + FarmID string `path:"farmId" required:"true" doc:"UUID of the farm to get analytics for" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"` // Changed path param name +} + +type GetFarmAnalyticsOutput struct { + Body domain.FarmAnalytics `json:"body"` +} + +// New Input Type for Crop Analytics +type GetCropAnalyticsInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + CropID string `path:"cropId" required:"true" doc:"UUID of the crop/cropland to get analytics for" example:"b2c3d4e5-f6a7-8901-2345-67890abcdef1"` // Changed path param name +} + +// New Output Type for Crop Analytics +type GetCropAnalyticsOutput struct { + Body domain.CropAnalytics `json:"body"` +} + +func (a *api) getFarmAnalyticsHandler(ctx context.Context, input *GetFarmAnalyticsInput) (*GetFarmAnalyticsOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed: " + err.Error()) + } + + if _, err := uuid.Parse(input.FarmID); err != nil { + return nil, huma.Error400BadRequest("Invalid Farm ID format.") + } + + analyticsData, err := a.analyticsRepo.GetFarmAnalytics(ctx, input.FarmID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + a.logger.Info("Analytics data not found for farm", "farm_id", input.FarmID) + return nil, huma.Error404NotFound("Analytics data not found for this farm.") + } + a.logger.Error("Failed to retrieve farm analytics", "farm_id", input.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve analytics data.") + } + + // Authorization Check: User must own the farm + if analyticsData.OwnerID != userID { + a.logger.Warn("User attempted to access analytics for farm they do not own", "user_id", userID, "farm_id", input.FarmID, "owner_id", analyticsData.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to view analytics for this farm.") + } + + resp := &GetFarmAnalyticsOutput{ + Body: *analyticsData, + } + return resp, nil +} + +// New Handler for Crop Analytics +func (a *api) getCropAnalyticsHandler(ctx context.Context, input *GetCropAnalyticsInput) (*GetCropAnalyticsOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed: " + err.Error()) + } + + if _, err := uuid.Parse(input.CropID); err != nil { + return nil, huma.Error400BadRequest("Invalid Crop ID format.") + } + + // Fetch Crop Analytics Data + cropAnalytics, err := a.analyticsRepo.GetCropAnalytics(ctx, input.CropID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + a.logger.Info("Crop analytics data not found", "crop_id", input.CropID) + return nil, huma.Error404NotFound("Crop analytics data not found.") + } + a.logger.Error("Failed to retrieve crop analytics", "crop_id", input.CropID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve crop analytics data.") + } + + // Authorization Check: Verify user owns the farm this crop belongs to + farm, err := a.farmRepo.GetByID(ctx, cropAnalytics.FarmID) + if err != nil { + // This case is less likely if cropAnalytics was found, but handle defensively + if errors.Is(err, domain.ErrNotFound) { + a.logger.Error("Farm associated with crop not found", "farm_id", cropAnalytics.FarmID, "crop_id", input.CropID) + return nil, huma.Error404NotFound("Associated farm not found.") + } + a.logger.Error("Failed to retrieve farm for authorization check", "farm_id", cropAnalytics.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to verify ownership.") + } + + if farm.OwnerID != userID { + a.logger.Warn("User attempted to access analytics for crop on farm they do not own", "user_id", userID, "crop_id", input.CropID, "farm_id", cropAnalytics.FarmID) + return nil, huma.Error403Forbidden("You are not authorized to view analytics for this crop.") + } + + // Return the fetched data + resp := &GetCropAnalyticsOutput{ + Body: *cropAnalytics, + } + return resp, nil +} diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index ee3fd15..9a8c77e 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -2,9 +2,12 @@ package api import ( "context" + "errors" "fmt" "log/slog" "net/http" + "strings" + "time" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humachi" @@ -13,14 +16,18 @@ import ( "github.com/go-chi/cors" "github.com/jackc/pgx/v5/pgxpool" + "github.com/forfarm/backend/internal/config" "github.com/forfarm/backend/internal/domain" m "github.com/forfarm/backend/internal/middlewares" "github.com/forfarm/backend/internal/repository" + "github.com/forfarm/backend/internal/services/weather" + "github.com/forfarm/backend/internal/utilities" ) type api struct { - logger *slog.Logger - httpClient *http.Client + logger *slog.Logger + httpClient *http.Client + eventPublisher domain.EventPublisher userRepo domain.UserRepository cropRepo domain.CroplandRepository @@ -28,35 +35,76 @@ type api struct { plantRepo domain.PlantRepository inventoryRepo domain.InventoryRepository harvestRepo domain.HarvestRepository - knowledgeHubRepo domain.KnowledgeHubRepository + analyticsRepo domain.AnalyticsRepository + knowledgeHubRepo domain.KnowledgeHubRepository + + weatherFetcher domain.WeatherFetcher } -func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api { +var weatherFetcherInstance domain.WeatherFetcher + +func GetWeatherFetcher() domain.WeatherFetcher { + return weatherFetcherInstance +} + +func NewAPI( + ctx context.Context, + logger *slog.Logger, + pool *pgxpool.Pool, + eventPublisher domain.EventPublisher, + analyticsRepo domain.AnalyticsRepository, + inventoryRepo domain.InventoryRepository, + croplandRepo domain.CroplandRepository, + farmRepo domain.FarmRepository, +) *api { client := &http.Client{} userRepository := repository.NewPostgresUser(pool) - croplandRepository := repository.NewPostgresCropland(pool) - farmRepository := repository.NewPostgresFarm(pool) plantRepository := repository.NewPostgresPlant(pool) knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool) inventoryRepository := repository.NewPostgresInventory(pool) harvestRepository := repository.NewPostgresHarvest(pool) + owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger) + cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL) + if err != nil { + logger.Warn("Invalid OPENWEATHER_CACHE_TTL format, using default 15m", "value", config.OPENWEATHER_CACHE_TTL, "error", err) + cacheTTL = 15 * time.Minute + } + cleanupInterval := cacheTTL * 2 + if cleanupInterval < 5*time.Minute { + cleanupInterval = 5 * time.Minute + } + cachedWeatherFetcher := weather.NewCachedWeatherFetcher(owmFetcher, cacheTTL, cleanupInterval, logger) + weatherFetcherInstance = cachedWeatherFetcher + return &api{ - logger: logger, - httpClient: client, + logger: logger, + httpClient: client, + eventPublisher: eventPublisher, userRepo: userRepository, - cropRepo: croplandRepository, - farmRepo: farmRepository, + cropRepo: croplandRepo, + farmRepo: farmRepo, plantRepo: plantRepository, - inventoryRepo: inventoryRepository, + inventoryRepo: inventoryRepo, harvestRepo: harvestRepository, - knowledgeHubRepo: knowledgeHubRepository, + analyticsRepo: analyticsRepo, + knowledgeHubRepo: knowledgeHubRepository, + weatherFetcher: cachedWeatherFetcher, } } +func (a *api) getUserIDFromHeader(authHeader string) (string, error) { + const bearerPrefix = "Bearer " + if !strings.HasPrefix(authHeader, bearerPrefix) { + return "", errors.New("invalid authorization header") + } + tokenString := strings.TrimPrefix(authHeader, bearerPrefix) + return utilities.ExtractUUIDFromToken(tokenString) +} + func (a *api) Server(port int) *http.Server { return &http.Server{ Addr: fmt.Sprintf(":%d", port), @@ -69,7 +117,7 @@ func (a *api) Routes() *chi.Mux { router.Use(cors.Handler(cors.Options{ // AllowedOrigins: []string{"https://foo.com"}, // Use this to allow specific origin hosts - AllowedOrigins: []string{"https://*", "http://*"}, + AllowedOrigins: []string{"https://*", "http://*", "http://localhost:3000"}, // AllowOriginFunc: func(r *http.Request, origin string) bool { return true }, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, @@ -85,8 +133,9 @@ func (a *api) Routes() *chi.Mux { a.registerAuthRoutes(r, api) a.registerCropRoutes(r, api) a.registerPlantRoutes(r, api) - a.registerOauthRoutes(r, api) a.registerKnowledgeHubRoutes(r, api) + a.registerOauthRoutes(r, api) + a.registerInventoryRoutes(r, api) }) router.Group(func(r chi.Router) { @@ -94,7 +143,7 @@ func (a *api) Routes() *chi.Mux { a.registerHelloRoutes(r, api) a.registerFarmRoutes(r, api) a.registerUserRoutes(r, api) - a.registerInventoryRoutes(r, api) + a.registerAnalyticsRoutes(r, api) }) return router diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index bb80493..a53dcfc 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -55,6 +55,7 @@ type RegisterInput struct { type RegisterOutput struct { Body struct { + // Use camelCase for JSON tags Token string `json:"token" example:"JWT token for the user"` } } @@ -85,73 +86,97 @@ func (a *api) registerHandler(ctx context.Context, input *RegisterInput) (*Regis } if err := validateEmail(input.Body.Email); err != nil { - return nil, err + // Return validation error in a structured way if Huma supports it, otherwise basic error + return nil, huma.Error422UnprocessableEntity("Validation failed", err) } if err := validatePassword(input.Body.Password); err != nil { - return nil, err + return nil, huma.Error422UnprocessableEntity("Validation failed", err) } _, err := a.userRepo.GetByEmail(ctx, input.Body.Email) - if err == domain.ErrNotFound { - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Body.Password), bcrypt.DefaultCost) - if err != nil { - return nil, err + // Check if the error is specifically ErrNotFound + if errors.Is(err, domain.ErrNotFound) { + hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(input.Body.Password), bcrypt.DefaultCost) + if hashErr != nil { + a.logger.Error("Failed to hash password during registration", "error", hashErr) + return nil, huma.Error500InternalServerError("Registration failed due to internal error") } - err = a.userRepo.CreateOrUpdate(ctx, &domain.User{ + newUser := &domain.User{ Email: input.Body.Email, Password: string(hashedPassword), - }) - if err != nil { - return nil, err + IsActive: true, } - user, err := a.userRepo.GetByEmail(ctx, input.Body.Email) - if err != nil { - return nil, err + createErr := a.userRepo.CreateOrUpdate(ctx, newUser) + if createErr != nil { + a.logger.Error("Failed to create user", "email", input.Body.Email, "error", createErr) + // Check for specific database errors if needed (e.g., unique constraint violation) + return nil, huma.Error500InternalServerError("Failed to register user") } - token, err := utilities.CreateJwtToken(user.UUID) - if err != nil { - return nil, err + token, tokenErr := utilities.CreateJwtToken(newUser.UUID) + if tokenErr != nil { + a.logger.Error("Failed to create JWT token after registration", "user_uuid", newUser.UUID, "error", tokenErr) + // Consider how to handle this - user is created but can't log in immediately. + // Maybe log the error and return success but without a token? Or return an error. + return nil, huma.Error500InternalServerError("Registration partially succeeded, but failed to generate token") } resp.Body.Token = token return resp, nil + } else if err == nil { + return nil, huma.Error409Conflict("User with this email already exists") + } else { + // Other database error occurred during GetByEmail + a.logger.Error("Database error checking user email", "email", input.Body.Email, "error", err) + return nil, huma.Error500InternalServerError("Failed to check user existence") } - - return nil, errors.New("user already exists") } func (a *api) loginHandler(ctx context.Context, input *LoginInput) (*LoginOutput, error) { resp := &LoginOutput{} if input == nil { - return nil, errors.New("invalid input") + return nil, huma.Error400BadRequest("Invalid input: missing request body") } if input.Body.Email == "" { - return nil, errors.New("email field is required") + return nil, huma.Error400BadRequest("Email field is required") } if input.Body.Password == "" { - return nil, errors.New("password field is required") + return nil, huma.Error400BadRequest("Password field is required") } if err := validateEmail(input.Body.Email); err != nil { - return nil, err + return nil, huma.Error422UnprocessableEntity("Validation failed", err) } user, err := a.userRepo.GetByEmail(ctx, input.Body.Email) if err != nil { - return nil, err + if errors.Is(err, domain.ErrNotFound) { + a.logger.Warn("Login attempt for non-existent user", "email", input.Body.Email) + return nil, huma.Error401Unauthorized("Invalid email or password") // Generic error for security + } + a.logger.Error("Database error during login lookup", "email", input.Body.Email, "error", err) + return nil, huma.Error500InternalServerError("Login failed due to an internal error") + } + + // Check if the user is active + if !user.IsActive { + a.logger.Warn("Login attempt for inactive user", "email", input.Body.Email, "user_uuid", user.UUID) + return nil, huma.Error403Forbidden("Account is inactive") } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Body.Password)); err != nil { - return nil, err + a.logger.Warn("Incorrect password attempt", "email", input.Body.Email, "user_uuid", user.UUID) + // Do not differentiate between wrong email and wrong password for security + return nil, huma.Error401Unauthorized("Invalid email or password") } token, err := utilities.CreateJwtToken(user.UUID) if err != nil { - return nil, err + a.logger.Error("Failed to create JWT token during login", "user_uuid", user.UUID, "error", err) + return nil, huma.Error500InternalServerError("Failed to generate login token") } resp.Body.Token = token diff --git a/backend/internal/api/crop.go b/backend/internal/api/crop.go index 8ff12c7..477c360 100644 --- a/backend/internal/api/crop.go +++ b/backend/internal/api/crop.go @@ -2,6 +2,8 @@ package api import ( "context" + "database/sql" + "encoding/json" "errors" "net/http" @@ -11,7 +13,6 @@ import ( "github.com/gofrs/uuid" ) -// Register the crop routes func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { tags := []string{"crop"} @@ -37,7 +38,7 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { huma.Register(api, huma.Operation{ OperationID: "getAllCroplandsByFarmID", Method: http.MethodGet, - Path: prefix + "/farm/{farm_id}", + Path: prefix + "/farm/{farmId}", Tags: tags, }, a.getAllCroplandsByFarmIDHandler) @@ -50,117 +51,172 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { }, a.createOrUpdateCroplandHandler) } -// Response structure for all croplands type GetCroplandsOutput struct { Body struct { Croplands []domain.Cropland `json:"croplands"` - } `json:"body"` + } } -// Response structure for single cropland by ID type GetCroplandByIDOutput struct { Body struct { Cropland domain.Cropland `json:"cropland"` - } `json:"body"` + } } -// Request structure for creating or updating a cropland type CreateOrUpdateCroplandInput struct { - Body struct { - UUID string `json:"uuid,omitempty"` // Optional for create, required for update - Name string `json:"name"` - Status string `json:"status"` - Priority int `json:"priority"` - LandSize float64 `json:"land_size"` - GrowthStage string `json:"growth_stage"` - PlantID string `json:"plant_id"` - FarmID string `json:"farm_id"` - } `json:"body"` + Header string `header:"Authorization" required:"true" example:"Bearer token"` + Body struct { + UUID string `json:"uuid,omitempty"` + Name string `json:"name"` + Status string `json:"status"` + Priority int `json:"priority"` + LandSize float64 `json:"landSize"` + GrowthStage string `json:"growthStage"` + PlantID string `json:"plantId"` + FarmID string `json:"farmId"` + GeoFeature json.RawMessage `json:"geoFeature,omitempty"` + } } -// Response structure for creating or updating a cropland type CreateOrUpdateCroplandOutput struct { Body struct { Cropland domain.Cropland `json:"cropland"` - } `json:"body"` + } } -// GetAllCroplands handles GET /crop endpoint -func (a *api) getAllCroplandsHandler(ctx context.Context, input *struct{}) (*GetCroplandsOutput, error) { +// --- Handlers --- + +func (a *api) getAllCroplandsHandler(ctx context.Context, input *struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` +}) (*GetCroplandsOutput, error) { + // Note: This currently fetches ALL croplands. Might need owner filtering later. + // For now, ensure authentication happens. + _, err := a.getUserIDFromHeader(input.Header) // Verify token + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + resp := &GetCroplandsOutput{} - // Fetch all croplands without filtering by farmID - croplands, err := a.cropRepo.GetAll(ctx) // Use the GetAll method + croplands, err := a.cropRepo.GetAll(ctx) if err != nil { - return nil, err + a.logger.Error("Failed to get all croplands", "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve croplands") } resp.Body.Croplands = croplands return resp, nil } -// GetCroplandByID handles GET /crop/{uuid} endpoint func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct { - UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` + Header string `header:"Authorization" required:"true" example:"Bearer token"` + UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` }) (*GetCroplandByIDOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) // Verify token and get user ID + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + resp := &GetCroplandByIDOutput{} - // Validate the UUID format if input.UUID == "" { return nil, huma.Error400BadRequest("UUID parameter is required") } - // Check if the UUID is in a valid format - _, err := uuid.FromString(input.UUID) + _, err = uuid.FromString(input.UUID) if err != nil { - return nil, huma.Error400BadRequest("invalid UUID format") + return nil, huma.Error400BadRequest("Invalid UUID format") } - // Fetch cropland by ID cropland, err := a.cropRepo.GetByID(ctx, input.UUID) if err != nil { - if errors.Is(err, domain.ErrNotFound) { - return nil, huma.Error404NotFound("cropland not found") + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Warn("Cropland not found", "croplandId", input.UUID, "requestingUserId", userID) + return nil, huma.Error404NotFound("Cropland not found") } - return nil, err + a.logger.Error("Failed to get cropland by ID", "croplandId", input.UUID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve cropland") + } + + // Authorization check: User must own the farm this cropland belongs to + farm, err := a.farmRepo.GetByID(ctx, cropland.FarmID) // Fetch the farm + if err != nil { + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Error("Farm associated with cropland not found", "farmId", cropland.FarmID, "croplandId", input.UUID) + // This indicates a data integrity issue if the cropland exists but farm doesn't + return nil, huma.Error404NotFound("Associated farm not found for cropland") + } + a.logger.Error("Failed to fetch farm for cropland authorization", "farmId", cropland.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to verify ownership") + } + + if farm.OwnerID != userID { + a.logger.Warn("Unauthorized attempt to access cropland", "croplandId", input.UUID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to view this cropland") } resp.Body.Cropland = cropland return resp, nil } -// GetAllCroplandsByFarmID handles GET /crop/farm/{farm_id} endpoint func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct { - FarmID string `path:"farm_id" example:"550e8400-e29b-41d4-a716-446655440000"` + Header string `header:"Authorization" required:"true" example:"Bearer token"` + FarmID string `path:"farmId" example:"550e8400-e29b-41d4-a716-446655440000"` }) (*GetCroplandsOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + resp := &GetCroplandsOutput{} - // Validate the FarmID format if input.FarmID == "" { - return nil, huma.Error400BadRequest("FarmID parameter is required") + return nil, huma.Error400BadRequest("farm_id parameter is required") } - // Check if the FarmID is in a valid format - _, err := uuid.FromString(input.FarmID) + farmUUID, err := uuid.FromString(input.FarmID) if err != nil { - return nil, huma.Error400BadRequest("invalid FarmID format") + return nil, huma.Error400BadRequest("Invalid farmId format") + } + + // Authorization check: User must own the farm they are requesting crops for + farm, err := a.farmRepo.GetByID(ctx, farmUUID.String()) + if err != nil { + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Warn("Attempt to get crops for non-existent farm", "farmId", input.FarmID, "requestingUserId", userID) + return nil, huma.Error404NotFound("Farm not found") + } + a.logger.Error("Failed to fetch farm for cropland list authorization", "farmId", input.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to verify ownership") + } + if farm.OwnerID != userID { + a.logger.Warn("Unauthorized attempt to list crops for farm", "farmId", input.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to view crops for this farm") } - // Fetch croplands by FarmID croplands, err := a.cropRepo.GetByFarmID(ctx, input.FarmID) if err != nil { - return nil, err + a.logger.Error("Failed to get croplands by farm ID", "farmId", input.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve croplands for farm") + } + + if croplands == nil { + croplands = []domain.Cropland{} } resp.Body.Croplands = croplands return resp, nil } -// CreateOrUpdateCropland handles POST /crop endpoint func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOrUpdateCroplandInput) (*CreateOrUpdateCroplandOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + resp := &CreateOrUpdateCroplandOutput{} - // Validate required fields + // --- Input Validation --- if input.Body.Name == "" { return nil, huma.Error400BadRequest("name is required") } @@ -168,24 +224,65 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr return nil, huma.Error400BadRequest("status is required") } if input.Body.GrowthStage == "" { - return nil, huma.Error400BadRequest("growth_stage is required") + return nil, huma.Error400BadRequest("growthStage is required") } if input.Body.PlantID == "" { - return nil, huma.Error400BadRequest("plant_id is required") + return nil, huma.Error400BadRequest("plantId is required") } if input.Body.FarmID == "" { - return nil, huma.Error400BadRequest("farm_id is required") + return nil, huma.Error400BadRequest("farmId is required") } - // Validate UUID if provided + // Validate UUID formats if input.Body.UUID != "" { - _, err := uuid.FromString(input.Body.UUID) - if err != nil { - return nil, huma.Error400BadRequest("invalid UUID format") + if _, err := uuid.FromString(input.Body.UUID); err != nil { + return nil, huma.Error400BadRequest("invalid cropland UUID format") + } + } + if _, err := uuid.FromString(input.Body.PlantID); err != nil { + return nil, huma.Error400BadRequest("invalid plantId UUID format") + } + farmUUID, err := uuid.FromString(input.Body.FarmID) + if err != nil { + return nil, huma.Error400BadRequest("invalid farm_id UUID format") + } + + // Validate JSON format if GeoFeature is provided + if input.Body.GeoFeature != nil && !json.Valid(input.Body.GeoFeature) { + return nil, huma.Error400BadRequest("invalid JSON format for geoFeature") + } + + // --- Authorization Check --- + // User must own the farm they are adding/updating a crop for + farm, err := a.farmRepo.GetByID(ctx, farmUUID.String()) + if err != nil { + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Warn("Attempt to create/update crop for non-existent farm", "farmId", input.Body.FarmID, "requestingUserId", userID) + return nil, huma.Error404NotFound("Target farm not found") + } + a.logger.Error("Failed to fetch farm for create/update cropland authorization", "farmId", input.Body.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to verify ownership") + } + if farm.OwnerID != userID { + a.logger.Warn("Unauthorized attempt to create/update crop on farm", "farmId", input.Body.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to modify crops on this farm") + } + + // If updating, ensure the user also owns the existing cropland (redundant if farm check passes, but good practice) + if input.Body.UUID != "" { + existingCrop, err := a.cropRepo.GetByID(ctx, input.Body.UUID) + if err != nil && !errors.Is(err, domain.ErrNotFound) && !errors.Is(err, sql.ErrNoRows) { // Ignore not found for creation + a.logger.Error("Failed to get existing cropland for update authorization check", "croplandId", input.Body.UUID, "error", err) + return nil, huma.Error500InternalServerError("Failed to verify existing cropland") + } + // If cropland exists and its FarmID doesn't match the input/authorized FarmID, deny. + if err == nil && existingCrop.FarmID != farmUUID.String() { + a.logger.Warn("Attempt to update cropland belonging to a different farm", "croplandId", input.Body.UUID, "inputFarmId", input.Body.FarmID, "actualFarmId", existingCrop.FarmID) + return nil, huma.Error403Forbidden("Cropland does not belong to the specified farm") } } - // Map input to domain.Cropland + // --- Prepare and Save Cropland --- cropland := &domain.Cropland{ UUID: input.Body.UUID, Name: input.Body.Name, @@ -195,15 +292,18 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr GrowthStage: input.Body.GrowthStage, PlantID: input.Body.PlantID, FarmID: input.Body.FarmID, + GeoFeature: input.Body.GeoFeature, } - // Create or update the cropland - err := a.cropRepo.CreateOrUpdate(ctx, cropland) + // Use the repository's CreateOrUpdate which handles assigning UUID if needed + err = a.cropRepo.CreateOrUpdate(ctx, cropland) if err != nil { - return nil, err + a.logger.Error("Failed to save cropland to database", "farm_id", input.Body.FarmID, "plantId", input.Body.PlantID, "error", err) + return nil, huma.Error500InternalServerError("Failed to save cropland") } - // Return the created/updated cropland + a.logger.Info("Cropland created/updated successfully", "croplandId", cropland.UUID, "farmId", cropland.FarmID) + resp.Body.Cropland = *cropland return resp, nil } diff --git a/backend/internal/api/farm.go b/backend/internal/api/farm.go index 4820e11..7d611cb 100644 --- a/backend/internal/api/farm.go +++ b/backend/internal/api/farm.go @@ -2,6 +2,9 @@ package api import ( "context" + "database/sql" + "errors" + "fmt" "net/http" "github.com/danielgtaylor/huma/v2" @@ -9,9 +12,24 @@ import ( "github.com/go-chi/chi/v5" ) +// registerFarmRoutes defines endpoints for farm operations. func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) { tags := []string{"farm"} - prefix := "/farm" + prefix := "/farms" + + huma.Register(api, huma.Operation{ + OperationID: "getAllFarms", + Method: http.MethodGet, + Path: prefix, + Tags: tags, + }, a.getAllFarmsHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getFarmByID", + Method: http.MethodGet, + Path: prefix + "/{farmId}", + Tags: tags, + }, a.getFarmByIDHandler) huma.Register(api, huma.Operation{ OperationID: "createFarm", @@ -21,34 +39,32 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) { }, a.createFarmHandler) huma.Register(api, huma.Operation{ - OperationID: "getFarmsByOwner", - Method: http.MethodGet, - Path: prefix + "/owner/{owner_id}", + OperationID: "updateFarm", + Method: http.MethodPut, + Path: prefix + "/{farmId}", Tags: tags, - }, a.getFarmsByOwnerHandler) - - huma.Register(api, huma.Operation{ - OperationID: "getFarmByID", - Method: http.MethodGet, - Path: prefix + "/{farm_id}", - Tags: tags, - }, a.getFarmByIDHandler) + }, a.updateFarmHandler) huma.Register(api, huma.Operation{ OperationID: "deleteFarm", Method: http.MethodDelete, - Path: prefix + "/{farm_id}", + Path: prefix + "/{farmId}", Tags: tags, }, a.deleteFarmHandler) } +// +// Input and Output types +// + type CreateFarmInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` Body struct { - Name string `json:"name"` - Lat []float64 `json:"lat"` - Lon []float64 `json:"lon"` - OwnerID string `json:"owner_id"` + Name string `json:"name" required:"true"` + Lat float64 `json:"lat" required:"true"` + Lon float64 `json:"lon" required:"true"` + FarmType string `json:"farmType,omitempty"` + TotalSize string `json:"totalSize,omitempty"` } } @@ -58,63 +74,42 @@ type CreateFarmOutput struct { } } -func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*CreateFarmOutput, error) { - farm := &domain.Farm{ - Name: input.Body.Name, - Lat: input.Body.Lat, - Lon: input.Body.Lon, - OwnerID: input.Body.OwnerID, - } - - err := a.farmRepo.CreateOrUpdate(ctx, farm) - if err != nil { - return nil, err - } - - return &CreateFarmOutput{Body: struct { - UUID string `json:"uuid"` - }{UUID: farm.UUID}}, nil +type GetAllFarmsInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` } -type GetFarmsByOwnerInput struct { - Header string `header:"Authorization" required:"true" example:"Bearer token"` - OwnerID string `path:"owner_id"` -} - -type GetFarmsByOwnerOutput struct { - Body []domain.Farm -} - -func (a *api) getFarmsByOwnerHandler(ctx context.Context, input *GetFarmsByOwnerInput) (*GetFarmsByOwnerOutput, error) { - farms, err := a.farmRepo.GetByOwnerID(ctx, input.OwnerID) - if err != nil { - return nil, err - } - - return &GetFarmsByOwnerOutput{Body: farms}, nil +type GetAllFarmsOutput struct { + Body []domain.Farm `json:"farms"` } type GetFarmByIDInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - FarmID string `path:"farm_id"` + FarmID string `path:"farmId" required:"true"` } type GetFarmByIDOutput struct { - Body domain.Farm + Body domain.Farm `json:"farm"` } -func (a *api) getFarmByIDHandler(ctx context.Context, input *GetFarmByIDInput) (*GetFarmByIDOutput, error) { - farm, err := a.farmRepo.GetByID(ctx, input.FarmID) - if err != nil { - return nil, err +type UpdateFarmInput struct { + Header string `header:"Authorization" required:"true" example:"Bearer token"` + FarmID string `path:"farmId" required:"true"` + Body struct { + Name *string `json:"name,omitempty"` + Lat *float64 `json:"lat,omitempty"` + Lon *float64 `json:"lon,omitempty"` + FarmType *string `json:"farmType,omitempty"` + TotalSize *string `json:"totalSize,omitempty"` } +} - return &GetFarmByIDOutput{Body: farm}, nil +type UpdateFarmOutput struct { + Body domain.Farm `json:"farm"` } type DeleteFarmInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - FarmID string `path:"farm_id"` + FarmID string `path:"farmId" required:"true"` } type DeleteFarmOutput struct { @@ -123,13 +118,196 @@ type DeleteFarmOutput struct { } } -func (a *api) deleteFarmHandler(ctx context.Context, input *DeleteFarmInput) (*DeleteFarmOutput, error) { - err := a.farmRepo.Delete(ctx, input.FarmID) +// +// API Handlers +// + +func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*CreateFarmOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) if err != nil { - return nil, err + return nil, huma.Error401Unauthorized("Authentication failed", err) } - return &DeleteFarmOutput{Body: struct { - Message string `json:"message"` - }{Message: "Farm deleted successfully"}}, nil + farm := &domain.Farm{ + Name: input.Body.Name, + Lat: input.Body.Lat, + Lon: input.Body.Lon, + FarmType: input.Body.FarmType, + TotalSize: input.Body.TotalSize, + OwnerID: userID, + } + + // Validate the farm object (optional but recommended) + // if err := farm.Validate(); err != nil { + // return nil, huma.Error422UnprocessableEntity("Validation failed", err) + // } + + fmt.Println("Creating farm:", farm) // Keep for debugging if needed + + if err := a.farmRepo.CreateOrUpdate(ctx, farm); err != nil { + a.logger.Error("Failed to create farm in database", "error", err, "ownerId", userID, "farmName", farm.Name) + return nil, huma.Error500InternalServerError("Failed to create farm") + } + + a.logger.Info("Farm created successfully", "farmId", farm.UUID, "ownerId", userID) + + return &CreateFarmOutput{ + Body: struct { + UUID string `json:"uuid"` + }{UUID: farm.UUID}, + }, nil +} + +func (a *api) getAllFarmsHandler(ctx context.Context, input *GetAllFarmsInput) (*GetAllFarmsOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + + farms, err := a.farmRepo.GetByOwnerID(ctx, userID) + if err != nil { + a.logger.Error("Failed to get farms by owner ID", "ownerId", userID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve farms") + } + + // Handle case where user has no farms (return empty list, not error) + if farms == nil { + farms = []domain.Farm{} + } + + return &GetAllFarmsOutput{Body: farms}, nil +} + +func (a *api) getFarmByIDHandler(ctx context.Context, input *GetFarmByIDInput) (*GetFarmByIDOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + + farm, err := a.farmRepo.GetByID(ctx, input.FarmID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { // Handle pgx ErrNoRows too + a.logger.Warn("Farm not found", "farmId", input.FarmID, "requestingUserId", userID) + return nil, huma.Error404NotFound("Farm not found") + } + a.logger.Error("Failed to get farm by ID", "farmId", input.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve farm") + } + + if farm.OwnerID != userID { + a.logger.Warn("Unauthorized attempt to access farm", "farmId", input.FarmID, "requestingUserId", userID, "ownerId", farm.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to view this farm") + } + + return &GetFarmByIDOutput{Body: *farm}, nil +} + +func (a *api) updateFarmHandler(ctx context.Context, input *UpdateFarmInput) (*UpdateFarmOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + + farm, err := a.farmRepo.GetByID(ctx, input.FarmID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Warn("Attempt to update non-existent farm", "farmId", input.FarmID, "requestingUserId", userID) + return nil, huma.Error404NotFound("Farm not found") + } + a.logger.Error("Failed to get farm for update", "farmId", input.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve farm for update") + } + + if farm.OwnerID != userID { + a.logger.Warn("Unauthorized attempt to update farm", "farmId", input.FarmID, "requestingUserId", userID, "ownerId", farm.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to update this farm") + } + + // Apply updates selectively + updated := false + if input.Body.Name != nil && *input.Body.Name != "" && *input.Body.Name != farm.Name { + farm.Name = *input.Body.Name + updated = true + } + if input.Body.Lat != nil && *input.Body.Lat != farm.Lat { + farm.Lat = *input.Body.Lat + updated = true + } + if input.Body.Lon != nil && *input.Body.Lon != farm.Lon { + farm.Lon = *input.Body.Lon + updated = true + } + if input.Body.FarmType != nil && *input.Body.FarmType != farm.FarmType { + farm.FarmType = *input.Body.FarmType + updated = true + } + if input.Body.TotalSize != nil && *input.Body.TotalSize != farm.TotalSize { + farm.TotalSize = *input.Body.TotalSize + updated = true + } + + if !updated { + a.logger.Info("No changes detected for farm update", "farmId", input.FarmID) + // Return the existing farm data as no update was needed + return &UpdateFarmOutput{Body: *farm}, nil + } + + // Validate updated farm object (optional but recommended) + // if err := farm.Validate(); err != nil { + // return nil, huma.Error422UnprocessableEntity("Validation failed after update", err) + // } + + if err = a.farmRepo.CreateOrUpdate(ctx, farm); err != nil { + a.logger.Error("Failed to update farm in database", "farmId", input.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to update farm") + } + + a.logger.Info("Farm updated successfully", "farmId", farm.UUID, "ownerId", userID) + + // Fetch the updated farm again to ensure we return the latest state (including UpdatedAt) + updatedFarm, fetchErr := a.farmRepo.GetByID(ctx, input.FarmID) + if fetchErr != nil { + a.logger.Error("Failed to fetch farm after update", "farmId", input.FarmID, "error", fetchErr) + // Return the potentially stale data from 'farm' as a fallback, but log the error + return &UpdateFarmOutput{Body: *farm}, nil + } + + return &UpdateFarmOutput{Body: *updatedFarm}, nil +} + +func (a *api) deleteFarmHandler(ctx context.Context, input *DeleteFarmInput) (*DeleteFarmOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) + if err != nil { + return nil, huma.Error401Unauthorized("Authentication failed", err) + } + + farm, err := a.farmRepo.GetByID(ctx, input.FarmID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Warn("Attempt to delete non-existent farm", "farmId", input.FarmID, "requestingUserId", userID) + // Consider returning 204 No Content if delete is idempotent + return nil, huma.Error404NotFound("Farm not found") + } + a.logger.Error("Failed to get farm for deletion", "farmId", input.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve farm for deletion") + } + + if farm.OwnerID != userID { + a.logger.Warn("Unauthorized attempt to delete farm", "farmId", input.FarmID, "requestingUserId", userID, "ownerId", farm.OwnerID) + return nil, huma.Error403Forbidden("You are not authorized to delete this farm") + } + + if err := a.farmRepo.Delete(ctx, input.FarmID); err != nil { + a.logger.Error("Failed to delete farm from database", "farmId", input.FarmID, "error", err) + // Consider potential FK constraint errors if crops aren't deleted automatically + return nil, huma.Error500InternalServerError("Failed to delete farm") + } + + a.logger.Info("Farm deleted successfully", "farmId", input.FarmID, "ownerId", userID) + + return &DeleteFarmOutput{ + Body: struct { + Message string `json:"message"` + }{Message: "Farm deleted successfully"}, + }, nil } diff --git a/backend/internal/api/inventory.go b/backend/internal/api/inventory.go index 17e7eca..04ca1cf 100644 --- a/backend/internal/api/inventory.go +++ b/backend/internal/api/inventory.go @@ -77,10 +77,10 @@ type InventoryItemResponse struct { Category InventoryCategory `json:"category"` Quantity float64 `json:"quantity"` Unit HarvestUnit `json:"unit"` - DateAdded time.Time `json:"date_added"` + DateAdded time.Time `json:"dateAdded"` Status InventoryStatus `json:"status"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` } type InventoryStatus struct { @@ -100,14 +100,13 @@ type HarvestUnit struct { type CreateInventoryItemInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` Body struct { Name string `json:"name" required:"true"` - CategoryID int `json:"category_id" required:"true"` + CategoryID int `json:"categoryId" required:"true"` Quantity float64 `json:"quantity" required:"true"` - UnitID int `json:"unit_id" required:"true"` - DateAdded time.Time `json:"date_added" required:"true"` - StatusID int `json:"status_id" required:"true"` + UnitID int `json:"unitId" required:"true"` + DateAdded time.Time `json:"dateAdded" required:"true"` + StatusID int `json:"statusId" required:"true"` } } @@ -119,15 +118,14 @@ type CreateInventoryItemOutput struct { type UpdateInventoryItemInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` ID string `path:"id"` Body struct { Name string `json:"name"` - CategoryID int `json:"category_id"` + CategoryID int `json:"categoryId"` Quantity float64 `json:"quantity"` - UnitID int `json:"unit_id"` - DateAdded time.Time `json:"date_added"` - StatusID int `json:"status_id"` + UnitID int `json:"unitId"` + DateAdded time.Time `json:"dateAdded"` + StatusID int `json:"statusId"` } } @@ -137,14 +135,13 @@ type UpdateInventoryItemOutput struct { type GetInventoryItemsInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` - CategoryID int `query:"category_id"` - StatusID int `query:"status_id"` - StartDate time.Time `query:"start_date" format:"date-time"` - EndDate time.Time `query:"end_date" format:"date-time"` + CategoryID int `query:"categoryId"` + StatusID int `query:"statusId"` + StartDate time.Time `query:"startDate" format:"date-time"` + EndDate time.Time `query:"endDate" format:"date-time"` SearchQuery string `query:"search"` - SortBy string `query:"sort_by" enum:"name,quantity,date_added,created_at"` - SortOrder string `query:"sort_order" enum:"asc,desc" default:"desc"` + SortBy string `query:"sortBy" enum:"name,quantity,dateAdded,createdAt"` + SortOrder string `query:"sortOrder" enum:"asc,desc" default:"desc"` } type GetInventoryItemsOutput struct { @@ -153,7 +150,7 @@ type GetInventoryItemsOutput struct { type GetInventoryItemInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` + UserID string `header:"userId" required:"true" example:"user-uuid"` ID string `path:"id"` } @@ -163,7 +160,6 @@ type GetInventoryItemOutput struct { type DeleteInventoryItemInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` ID string `path:"id"` } @@ -186,8 +182,9 @@ type GetHarvestUnitsOutput struct { } func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) item := &domain.InventoryItem{ - UserID: input.UserID, + UserID: userID, Name: input.Body.Name, CategoryID: input.Body.CategoryID, Quantity: input.Body.Quantity, @@ -200,7 +197,7 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven return nil, huma.Error422UnprocessableEntity(err.Error()) } - err := a.inventoryRepo.CreateOrUpdate(ctx, item) + err = a.inventoryRepo.CreateOrUpdate(ctx, item) if err != nil { return nil, err } @@ -211,8 +208,9 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven } func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) filter := domain.InventoryFilter{ - UserID: input.UserID, + UserID: userID, CategoryID: input.CategoryID, StatusID: input.StatusID, StartDate: input.StartDate, @@ -225,7 +223,7 @@ func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInve Direction: input.SortOrder, } - items, err := a.inventoryRepo.GetByUserID(ctx, input.UserID, filter, sort) + items, err := a.inventoryRepo.GetByUserID(ctx, userID, filter, sort) if err != nil { return nil, err } @@ -286,7 +284,8 @@ func (a *api) getInventoryItemHandler(ctx context.Context, input *GetInventoryIt } func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInventoryItemInput) (*UpdateInventoryItemOutput, error) { - item, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID) + userID, err := a.getUserIDFromHeader(input.Header) + item, err := a.inventoryRepo.GetByID(ctx, input.ID, userID) if err != nil { return nil, err } @@ -319,7 +318,7 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven return nil, err } - updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID) + updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID, userID) if err != nil { return nil, err } @@ -347,7 +346,8 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven } func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInventoryItemInput) (*DeleteInventoryItemOutput, error) { - err := a.inventoryRepo.Delete(ctx, input.ID, input.UserID) + userID, err := a.getUserIDFromHeader(input.Header) + err = a.inventoryRepo.Delete(ctx, input.ID, userID) if err != nil { return nil, err } diff --git a/backend/internal/api/oauth.go b/backend/internal/api/oauth.go index 5d04333..70ca8c6 100644 --- a/backend/internal/api/oauth.go +++ b/backend/internal/api/oauth.go @@ -3,6 +3,7 @@ package api import ( "context" "crypto/rand" + "database/sql" "errors" "net/http" @@ -25,7 +26,7 @@ func (a *api) registerOauthRoutes(_ chi.Router, apiInstance huma.API) { type ExchangeTokenInput struct { Body struct { - AccessToken string `json:"access_token" example:"Google ID token obtained after login"` + AccessToken string `json:"accessToken" required:"true" example:"Google ID token"` } } @@ -51,51 +52,71 @@ func generateRandomPassword(length int) (string, error) { return string(bytes), nil } -// exchangeHandler assumes the provided access token is a Google ID token. -// It verifies the token with Google, and if the user doesn't exist, -// it creates a new user with a randomly generated password before issuing your JWT. func (a *api) exchangeHandler(ctx context.Context, input *ExchangeTokenInput) (*ExchangeTokenOutput, error) { if input.Body.AccessToken == "" { - return nil, errors.New("access token is required") + return nil, huma.Error400BadRequest("accessToken is required") // Match JSON tag } googleUserID, email, err := utilities.ExtractGoogleUserID(input.Body.AccessToken) if err != nil { - return nil, errors.New("invalid Google ID token") + a.logger.Warn("Invalid Google ID token received", "error", err) + return nil, huma.Error401Unauthorized("Invalid Google ID token", err) + } + if email == "" { + a.logger.Error("Google token verification succeeded but email is missing", "googleUserId", googleUserID) + return nil, huma.Error500InternalServerError("Failed to retrieve email from Google token") } user, err := a.userRepo.GetByEmail(ctx, email) - if err == domain.ErrNotFound { - newPassword, err := generateRandomPassword(12) - if err != nil { - return nil, err + if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { + a.logger.Info("Creating new user from Google OAuth", "email", email, "googleUserId", googleUserID) + + newPassword, passErr := generateRandomPassword(16) // Increase length + if passErr != nil { + a.logger.Error("Failed to generate random password for OAuth user", "error", passErr) + return nil, huma.Error500InternalServerError("User creation failed (password generation)") } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) - if err != nil { - return nil, err + hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if hashErr != nil { + a.logger.Error("Failed to hash generated password for OAuth user", "error", hashErr) + return nil, huma.Error500InternalServerError("User creation failed (password hashing)") } newUser := &domain.User{ Email: email, - Password: string(hashedPassword), + Password: string(hashedPassword), // Store hashed random password + IsActive: true, // Activate user immediately + // Username can be initially empty or derived from email if needed } - if err := a.userRepo.CreateOrUpdate(ctx, newUser); err != nil { - return nil, err + if createErr := a.userRepo.CreateOrUpdate(ctx, newUser); createErr != nil { + a.logger.Error("Failed to save new OAuth user to database", "email", email, "error", createErr) + return nil, huma.Error500InternalServerError("Failed to create user account") } user = *newUser } else if err != nil { - return nil, err + a.logger.Error("Database error looking up user by email during OAuth", "email", email, "error", err) + return nil, huma.Error500InternalServerError("Failed to process login") } + // Ensure the existing user is active + if !user.IsActive { + a.logger.Warn("OAuth login attempt for inactive user", "email", email, "user_uuid", user.UUID) + return nil, huma.Error403Forbidden("Account is inactive") + } + + // Generate JWT for the user (either existing or newly created) token, err := utilities.CreateJwtToken(user.UUID) if err != nil { - return nil, err + a.logger.Error("Failed to create JWT token after OAuth exchange", "user_uuid", user.UUID, "error", err) + return nil, huma.Error500InternalServerError("Failed to generate session token") } output := &ExchangeTokenOutput{} output.Body.JWT = token - output.Body.Email = email - _ = googleUserID // Maybe need in the future + output.Body.Email = email // Return the email for frontend context + _ = googleUserID // Maybe log or store this association if needed later + + a.logger.Info("OAuth exchange successful", "email", email, "user_uuid", user.UUID) return output, nil } diff --git a/backend/internal/api/plant.go b/backend/internal/api/plant.go index 51add35..c933929 100644 --- a/backend/internal/api/plant.go +++ b/backend/internal/api/plant.go @@ -31,7 +31,12 @@ func (a *api) getAllPlantHandler(ctx context.Context, input *struct{}) (*GetAllP resp := &GetAllPlantsOutput{} plants, err := a.plantRepo.GetAll(ctx) if err != nil { - return nil, err + a.logger.Error("Failed to get all plants", "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve plants") + } + + if plants == nil { + plants = []domain.Plant{} } resp.Body.Plants = plants diff --git a/backend/internal/api/user.go b/backend/internal/api/user.go index f289582..8dd914e 100644 --- a/backend/internal/api/user.go +++ b/backend/internal/api/user.go @@ -2,6 +2,7 @@ package api import ( "context" + "errors" "fmt" "net/http" "strings" @@ -28,6 +29,7 @@ type getSelfDataInput struct { Authorization string `header:"Authorization" required:"true" example:"Bearer token"` } +// getSelfDataOutput uses domain.User which now has camelCase tags type getSelfDataOutput struct { Body struct { User domain.User `json:"user"` @@ -39,24 +41,33 @@ func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSel authHeader := input.Authorization if authHeader == "" { - return nil, fmt.Errorf("no authorization header provided") + return nil, huma.Error401Unauthorized("No authorization header provided") } authToken := strings.TrimPrefix(authHeader, "Bearer ") if authToken == "" { - return nil, fmt.Errorf("no token provided") + return nil, huma.Error401Unauthorized("No token provided in Authorization header") } uuid, err := utilities.ExtractUUIDFromToken(authToken) if err != nil { - return nil, err + a.logger.Warn("Failed to extract UUID from token", "error", err) + return nil, huma.Error401Unauthorized("Invalid or expired token", err) } user, err := a.userRepo.GetByUUID(ctx, uuid) if err != nil { - return nil, err + if errors.Is(err, domain.ErrNotFound) { + a.logger.Warn("User data not found for valid token UUID", "user_uuid", uuid) + return nil, huma.Error404NotFound(fmt.Sprintf("User data not found for UUID: %s", uuid)) + } + a.logger.Error("Failed to get user data by UUID", "user_uuid", uuid, "error", err) + return nil, huma.Error500InternalServerError("Failed to retrieve user data") } + // Ensure password is not included in the response (already handled by `json:"-"`) + // user.Password = "" // Redundant if json tag is "-" + resp.Body.User = user return resp, nil } diff --git a/backend/internal/cmd/api.go b/backend/internal/cmd/api.go index d257756..ed03441 100644 --- a/backend/internal/cmd/api.go +++ b/backend/internal/cmd/api.go @@ -1,17 +1,23 @@ +// backend/internal/cmd/api.go package cmd import ( "context" - "os" - + "fmt" "log/slog" "net/http" + "os" + "time" "github.com/spf13/cobra" "github.com/forfarm/backend/internal/api" "github.com/forfarm/backend/internal/cmdutil" "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" ) func APICmd(ctx context.Context) *cobra.Command { @@ -26,32 +32,92 @@ func APICmd(ctx context.Context) *cobra.Command { pool, err := cmdutil.NewDatabasePool(ctx, 16) if err != nil { + logger.Error("failed to create database pool", "error", err) return err } defer pool.Close() - logger.Info("connected to database") - api := api.NewAPI(ctx, logger, pool) - server := api.Server(port) + // --- Event Bus --- + eventBus, err := event.NewRabbitMQEventBus(config.RABBITMQ_URL, logger) + if err != nil { + logger.Error("failed to connect to event bus", "url", config.RABBITMQ_URL, "error", err) + return fmt.Errorf("event bus connection failed: %w", err) + } + defer eventBus.Close() + logger.Info("connected to event bus", "url", config.RABBITMQ_URL) + 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) + + inventoryRepo := repository.NewPostgresInventory(pool, eventBus) + + croplandRepo := repository.NewPostgresCropland(pool) + croplandRepo.SetEventPublisher(eventBus) + + projection := event.NewFarmAnalyticsProjection(eventBus, analyticsRepo, logger) go func() { - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.Error("failed to start server", "err", err) + if err := projection.Start(ctx); err != nil { + logger.Error("FarmAnalyticsProjection failed to start listening", "error", err) } }() + logger.Info("Farm Analytics Projection started") - logger.Info("started API", "port", port) + weatherFetcher := api.GetWeatherFetcher() // Get fetcher instance from API setup + weatherInterval, err := time.ParseDuration(config.WEATHER_FETCH_INTERVAL) + if err != nil { + logger.Warn("Invalid WEATHER_FETCH_INTERVAL, using default 15m", "value", config.WEATHER_FETCH_INTERVAL, "error", err) + weatherInterval = 15 * time.Minute + } + weatherUpdater := workers.NewWeatherUpdater(farmRepo, weatherFetcher, eventBus, logger, weatherInterval) + weatherUpdater.Start(ctx) // Pass the main context + logger.Info("Weather Updater worker started", "interval", weatherInterval) - <-ctx.Done() + apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo) // Pass new repo + + server := apiInstance.Server(port) + + serverErrChan := make(chan error, 1) + go func() { + logger.Info("starting API server", "port", port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("API server failed", "error", err) + serverErrChan <- err // Send error to channel + } + close(serverErrChan) + }() + + select { + case err := <-serverErrChan: + logger.Error("Server error received, initiating shutdown.", "error", err) + case <-ctx.Done(): + logger.Info("Shutdown signal received, initiating graceful shutdown...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) // 15-second grace period + defer cancel() + + weatherUpdater.Stop() // Signal and wait + + if err := server.Shutdown(shutdownCtx); err != nil { + logger.Error("HTTP server graceful shutdown failed", "error", err) + } else { + logger.Info("HTTP server shutdown complete.") + } - if err := server.Shutdown(ctx); err != nil { - logger.Error("failed to gracefully shutdown server", "err", err) } + logger.Info("Application shutdown complete.") return nil }, } + // Add flags if needed (e.g., --port) + // cmd.Flags().IntVarP(&port, "port", "p", config.PORT, "Port for the API server") + return cmd } diff --git a/backend/internal/cmd/rollback.go b/backend/internal/cmd/rollback.go new file mode 100644 index 0000000..ae85d1c --- /dev/null +++ b/backend/internal/cmd/rollback.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "os" + "strconv" + + _ "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" + "github.com/spf13/cobra" + + "github.com/forfarm/backend/migrations" +) + +func RollbackCmd(ctx context.Context, dbDriver, dbSource string) *cobra.Command { + cmd := &cobra.Command{ + Use: "rollback [version]", + Short: "Rollback database migrations to a specific version", + Args: cobra.ExactArgs(1), // Ensure exactly one argument is provided + RunE: func(cmd *cobra.Command, args []string) error { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + + db, err := sql.Open(dbDriver, dbSource) + if err != nil { + return fmt.Errorf("failed to open database: %w", err) + } + defer db.Close() + + targetVersion := args[0] + targetVersionInt, err := strconv.Atoi(targetVersion) + if err != nil { + logger.Error("failed to convert version to integer", "version", targetVersion) + return err + } + targetVersionInt64 := int64(targetVersionInt) + + if err := goose.SetDialect(dbDriver); err != nil { + return fmt.Errorf("failed to set dialect: %w", err) + } + + if err := goose.DownTo(db, migrations.MigrationsDir, targetVersionInt64); err != nil { + return fmt.Errorf("failed to rollback to version %s: %w", targetVersion, err) + } + + logger.Info("Successfully rolled back to version", "version", targetVersion) + return nil + }, + } + + return cmd +} diff --git a/backend/internal/cmd/root.go b/backend/internal/cmd/root.go index df0b443..eced034 100644 --- a/backend/internal/cmd/root.go +++ b/backend/internal/cmd/root.go @@ -19,6 +19,7 @@ func Execute(ctx context.Context) int { rootCmd.AddCommand(APICmd(ctx)) rootCmd.AddCommand(MigrateCmd(ctx, "pgx", config.DATABASE_URL)) + rootCmd.AddCommand(RollbackCmd(ctx, "pgx", config.DATABASE_URL)) if err := rootCmd.Execute(); err != nil { return 1 diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 34ce9f8..9b9dbb2 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -7,15 +7,19 @@ import ( ) var ( - PORT int - POSTGRES_USER string - POSTGRES_PASSWORD string - POSTGRES_DB string - DATABASE_URL string - GOOGLE_CLIENT_ID string - GOOGLE_CLIENT_SECRET string - GOOGLE_REDIRECT_URL string - JWT_SECRET_KEY string + PORT int + POSTGRES_USER string + POSTGRES_PASSWORD string + POSTGRES_DB string + DATABASE_URL string + GOOGLE_CLIENT_ID string + GOOGLE_CLIENT_SECRET string + GOOGLE_REDIRECT_URL string + JWT_SECRET_KEY string + RABBITMQ_URL string + OPENWEATHER_API_KEY string + OPENWEATHER_CACHE_TTL string + WEATHER_FETCH_INTERVAL string ) func Load() { @@ -28,6 +32,10 @@ func Load() { viper.SetDefault("GOOGLE_CLIENT_SECRET", "google_client_secret") viper.SetDefault("JWT_SECRET_KEY", "jwt_secret_key") viper.SetDefault("GOOGLE_REDIRECT_URL", "http://localhost:8000/auth/login/google") + viper.SetDefault("RABBITMQ_URL", "amqp://user:password@localhost:5672/") + viper.SetDefault("OPENWEATHER_API_KEY", "openweather_api_key") + viper.SetDefault("OPENWEATHER_CACHE_TTL", "15m") + viper.SetDefault("WEATHER_FETCH_INTERVAL", "15m") viper.SetConfigFile(".env") viper.AddConfigPath("../../.") @@ -47,4 +55,8 @@ func Load() { GOOGLE_CLIENT_SECRET = viper.GetString("GOOGLE_CLIENT_SECRET") GOOGLE_REDIRECT_URL = viper.GetString("GOOGLE_REDIRECT_URL") JWT_SECRET_KEY = viper.GetString("JWT_SECRET_KEY") + RABBITMQ_URL = viper.GetString("RABBITMQ_URL") + OPENWEATHER_API_KEY = viper.GetString("OPENWEATHER_API_KEY") + OPENWEATHER_CACHE_TTL = viper.GetString("OPENWEATHER_CACHE_TTL") + WEATHER_FETCH_INTERVAL = viper.GetString("WEATHER_FETCH_INTERVAL") } diff --git a/backend/internal/domain/analytics.go b/backend/internal/domain/analytics.go new file mode 100644 index 0000000..41bd072 --- /dev/null +++ b/backend/internal/domain/analytics.go @@ -0,0 +1,67 @@ +package domain + +import ( + "context" + "time" +) + +type FarmAnalytics struct { + FarmID string `json:"farmId"` + FarmName string `json:"farmName"` + OwnerID string `json:"ownerId"` + FarmType *string `json:"farmType,omitempty"` + TotalSize *string `json:"totalSize,omitempty"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Weather *WeatherData + InventoryInfo struct { + TotalItems int `json:"totalItems"` + LowStockCount int `json:"lowStockCount"` + LastUpdated *time.Time `json:"lastUpdated,omitempty"` + } `json:"inventoryInfo"` + CropInfo struct { + TotalCount int `json:"totalCount"` + GrowingCount int `json:"growingCount"` + LastUpdated *time.Time `json:"lastUpdated,omitempty"` + } `json:"cropInfo"` + OverallStatus *string `json:"overallStatus,omitempty"` + AnalyticsLastUpdated time.Time `json:"analyticsLastUpdated"` +} + +type CropAnalytics struct { + CropID string `json:"cropId"` + CropName string `json:"cropName"` + FarmID string `json:"farmId"` + PlantName string `json:"plantName"` + 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 *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 { + Nitrogen *float64 `json:"nitrogen,omitempty"` + Phosphorus *float64 `json:"phosphorus,omitempty"` + Potassium *float64 `json:"potassium,omitempty"` + } `json:"nutrientLevels,omitempty"` + PlantHealth *string `json:"plantHealth,omitempty"` +} + +type AnalyticsRepository interface { + GetFarmAnalytics(ctx context.Context, farmID string) (*FarmAnalytics, error) + GetCropAnalytics(ctx context.Context, cropID string) (*CropAnalytics, error) + CreateOrUpdateFarmBaseData(ctx context.Context, farm *Farm) error + UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *WeatherData) error + UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error + UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error + DeleteFarmAnalytics(ctx context.Context, farmID string) error + UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error +} diff --git a/backend/internal/domain/cropland.go b/backend/internal/domain/cropland.go index 2da364d..e45b77d 100644 --- a/backend/internal/domain/cropland.go +++ b/backend/internal/domain/cropland.go @@ -2,22 +2,24 @@ package domain import ( "context" + "encoding/json" "time" validation "github.com/go-ozzo/ozzo-validation/v4" ) type Cropland struct { - UUID string - Name string - Status string - Priority int - LandSize float64 - GrowthStage string - PlantID string - FarmID string - CreatedAt time.Time - UpdatedAt time.Time + UUID string `json:"uuid"` + Name string `json:"name"` + Status string `json:"status"` + Priority int `json:"priority"` + LandSize float64 `json:"landSize"` + GrowthStage string `json:"growthStage"` + PlantID string `json:"plantId"` + FarmID string `json:"farmId"` + GeoFeature json.RawMessage `json:"geoFeature,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (c *Cropland) Validate() error { @@ -32,7 +34,8 @@ func (c *Cropland) Validate() error { type CroplandRepository interface { GetByID(context.Context, string) (Cropland, error) GetByFarmID(ctx context.Context, farmID string) ([]Cropland, error) - GetAll(ctx context.Context) ([]Cropland, error) // Add this method + GetAll(ctx context.Context) ([]Cropland, error) CreateOrUpdate(context.Context, *Cropland) error Delete(context.Context, string) error + SetEventPublisher(EventPublisher) } diff --git a/backend/internal/domain/event.go b/backend/internal/domain/event.go new file mode 100644 index 0000000..d7bb33c --- /dev/null +++ b/backend/internal/domain/event.go @@ -0,0 +1,28 @@ +package domain + +import ( + "context" + "time" +) + +type Event struct { + ID string + Type string + Source string + Timestamp time.Time + Payload interface{} + AggregateID string +} + +type EventPublisher interface { + Publish(ctx context.Context, event Event) error +} + +type EventSubscriber interface { + Subscribe(ctx context.Context, eventType string, handler func(Event) error) error +} + +type EventBus interface { + EventPublisher + EventSubscriber +} diff --git a/backend/internal/domain/farm.go b/backend/internal/domain/farm.go index 6422735..c2da327 100644 --- a/backend/internal/domain/farm.go +++ b/backend/internal/domain/farm.go @@ -8,13 +8,16 @@ import ( ) type Farm struct { - UUID string - Name string - Lat []float64 - Lon []float64 - CreatedAt time.Time - UpdatedAt time.Time - OwnerID string + UUID string `json:"uuid"` + Name string `json:"name"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + FarmType string `json:"farmType,omitempty"` + TotalSize string `json:"totalSize,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + OwnerID string `json:"ownerId"` + Crops []Cropland `json:"crops,omitempty"` } func (f *Farm) Validate() error { @@ -27,8 +30,9 @@ func (f *Farm) Validate() error { } type FarmRepository interface { - GetByID(context.Context, string) (Farm, error) + GetByID(context.Context, string) (*Farm, error) GetByOwnerID(context.Context, string) ([]Farm, error) CreateOrUpdate(context.Context, *Farm) error Delete(context.Context, string) error + SetEventPublisher(EventPublisher) } diff --git a/backend/internal/domain/inventory.go b/backend/internal/domain/inventory.go index 4b325d9..0471a59 100644 --- a/backend/internal/domain/inventory.go +++ b/backend/internal/domain/inventory.go @@ -23,19 +23,19 @@ type HarvestUnit struct { } type InventoryItem struct { - ID string - UserID string - Name string - CategoryID int - Category InventoryCategory - Quantity float64 - UnitID int - Unit HarvestUnit - DateAdded time.Time - StatusID int - Status InventoryStatus - CreatedAt time.Time - UpdatedAt time.Time + ID string `json:"id"` + UserID string `json:"userId"` + Name string `json:"name"` + CategoryID int `json:"categoryId"` + Category InventoryCategory `json:"category"` + Quantity float64 `json:"quantity"` + UnitID int `json:"unitId"` + Unit HarvestUnit `json:"unit"` + DateAdded time.Time `json:"dateAdded"` + StatusID int `json:"statusId"` + Status InventoryStatus `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } type InventoryFilter struct { diff --git a/backend/internal/domain/plant.go b/backend/internal/domain/plant.go index d69b6c2..289bca7 100644 --- a/backend/internal/domain/plant.go +++ b/backend/internal/domain/plant.go @@ -8,28 +8,28 @@ import ( ) type Plant struct { - UUID string - Name string - Variety *string - RowSpacing *float64 - OptimalTemp *float64 - PlantingDepth *float64 - AverageHeight *float64 - LightProfileID int - SoilConditionID int - PlantingDetail *string - IsPerennial bool - DaysToEmerge *int - DaysToFlower *int - DaysToMaturity *int - HarvestWindow *int - PHValue *float64 - EstimateLossRate *float64 - EstimateRevenuePerHU *float64 - HarvestUnitID int - WaterNeeds *float64 - CreatedAt time.Time - UpdatedAt time.Time + UUID string `json:"uuid"` + Name string `json:"name"` + Variety *string `json:"variety,omitempty"` + RowSpacing *float64 `json:"rowSpacing,omitempty"` + OptimalTemp *float64 `json:"optimalTemp,omitempty"` + PlantingDepth *float64 `json:"plantingDepth,omitempty"` + AverageHeight *float64 `json:"averageHeight,omitempty"` + LightProfileID int `json:"lightProfileId"` + SoilConditionID int `json:"soilConditionId"` + PlantingDetail *string `json:"plantingDetail,omitempty"` + IsPerennial bool `json:"isPerennial"` + DaysToEmerge *int `json:"daysToEmerge,omitempty"` + DaysToFlower *int `json:"daysToFlower,omitempty"` + DaysToMaturity *int `json:"daysToMaturity,omitempty"` + HarvestWindow *int `json:"harvestWindow,omitempty"` + PHValue *float64 `json:"phValue,omitempty"` + EstimateLossRate *float64 `json:"estimateLossRate,omitempty"` + EstimateRevenuePerHU *float64 `json:"estimateRevenuePerHu,omitempty"` + HarvestUnitID int `json:"harvestUnitId"` + WaterNeeds *float64 `json:"waterNeeds,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` } func (p *Plant) Validate() error { diff --git a/backend/internal/domain/user.go b/backend/internal/domain/user.go index b87fc0b..ae37801 100644 --- a/backend/internal/domain/user.go +++ b/backend/internal/domain/user.go @@ -11,14 +11,14 @@ import ( ) type User struct { - ID int64 - UUID string - Username string - Password string - Email string - CreatedAt time.Time - UpdatedAt time.Time - IsActive bool + ID int64 `json:"id"` + UUID string `json:"uuid"` + Username string `json:"username,omitempty"` + Password string `json:"-"` + Email string `json:"email"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + IsActive bool `json:"isActive"` } func (u *User) NormalizedUsername() string { @@ -29,16 +29,27 @@ func (u *User) Validate() error { return validation.ValidateStruct(u, validation.Field(&u.UUID, validation.Required), validation.Field(&u.Username, validation.By(func(value interface{}) error { + // Username is now optional if value == nil { return nil } - if value == "" { + if strVal, ok := value.(string); ok && strVal == "" { return nil } username, ok := value.(*string) if !ok { + // If it's a string but not a pointer, handle it + if strVal, ok := value.(string); ok { + if len(strVal) < 3 || len(strVal) > 20 { + return errors.New("username length must be between 3 and 20") + } + return nil + } return errors.New("invalid type for username") } + if username == nil || *username == "" { + return nil // Optional field is valid if empty or nil + } if len(*username) < 3 || len(*username) > 20 { return errors.New("username length must be between 3 and 20") } diff --git a/backend/internal/domain/weather.go b/backend/internal/domain/weather.go new file mode 100644 index 0000000..d375f7b --- /dev/null +++ b/backend/internal/domain/weather.go @@ -0,0 +1,21 @@ +package domain + +import ( + "context" + "time" +) + +type WeatherData struct { + TempCelsius *float64 `json:"tempCelsius,omitempty"` + Humidity *float64 `json:"humidity,omitempty"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + WindSpeed *float64 `json:"windSpeed,omitempty"` + RainVolume1h *float64 `json:"rainVolume1h,omitempty"` + ObservedAt *time.Time `json:"observedAt,omitempty"` + WeatherLastUpdated *time.Time `json:"weatherLastUpdated,omitempty"` +} + +type WeatherFetcher interface { + GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*WeatherData, error) +} diff --git a/backend/internal/event/aggregator.go b/backend/internal/event/aggregator.go new file mode 100644 index 0000000..05d587e --- /dev/null +++ b/backend/internal/event/aggregator.go @@ -0,0 +1,64 @@ +package event + +import ( + "context" + "log/slog" + "time" + + "github.com/forfarm/backend/internal/domain" +) + +type EventAggregator struct { + sourceSubscriber domain.EventSubscriber + targetPublisher domain.EventPublisher + logger *slog.Logger +} + +func NewEventAggregator( + sourceSubscriber domain.EventSubscriber, + targetPublisher domain.EventPublisher, + logger *slog.Logger, +) *EventAggregator { + return &EventAggregator{ + sourceSubscriber: sourceSubscriber, + targetPublisher: targetPublisher, + logger: logger, + } +} + +func (a *EventAggregator) Start(ctx context.Context) error { + // Subscribe to fine-grained events + eventTypes := []string{ + "farm.created", "farm.updated", "farm.deleted", + "weather.updated", "inventory.changed", "marketplace.transaction", + } + + for _, eventType := range eventTypes { + if err := a.sourceSubscriber.Subscribe(ctx, eventType, a.handleEvent); err != nil { + return err + } + } + + return nil +} + +func (a *EventAggregator) handleEvent(event domain.Event) error { + // Logic to aggregate events + // For example, combine farm and weather events into a farm status event + + if event.Type == "farm.created" || event.Type == "farm.updated" { + // Create a coarse-grained event + aggregatedEvent := domain.Event{ + ID: event.ID, + Type: "farm.status_changed", + Source: "event-aggregator", + Timestamp: time.Now(), + Payload: event.Payload, + AggregateID: event.AggregateID, + } + + return a.targetPublisher.Publish(context.Background(), aggregatedEvent) + } + + return nil +} diff --git a/backend/internal/event/eventbus.go b/backend/internal/event/eventbus.go new file mode 100644 index 0000000..bcb788e --- /dev/null +++ b/backend/internal/event/eventbus.go @@ -0,0 +1,150 @@ +package event + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/forfarm/backend/internal/domain" + amqp "github.com/rabbitmq/amqp091-go" +) + +type RabbitMQEventBus struct { + conn *amqp.Connection + channel *amqp.Channel + logger *slog.Logger +} + +func NewRabbitMQEventBus(url string, logger *slog.Logger) (*RabbitMQEventBus, error) { + conn, err := amqp.Dial(url) + if err != nil { + return nil, err + } + + ch, err := conn.Channel() + if err != nil { + conn.Close() + return nil, err + } + + // Declare the exchange + err = ch.ExchangeDeclare( + "events", // name + "topic", // type + true, // durable + false, // auto-deleted + false, // internal + false, // no-wait + nil, // arguments + ) + if err != nil { + ch.Close() + conn.Close() + return nil, err + } + + return &RabbitMQEventBus{ + conn: conn, + channel: ch, + logger: logger, + }, nil +} + +func (r *RabbitMQEventBus) Publish(ctx context.Context, event domain.Event) error { + data, err := json.Marshal(event) + if err != nil { + return err + } + + return r.channel.PublishWithContext( + ctx, + "events", // exchange + "events."+event.Type, // routing key + false, // mandatory + false, // immediate + amqp.Publishing{ + ContentType: "application/json", + Body: data, + DeliveryMode: amqp.Persistent, + MessageId: event.ID, + Timestamp: event.Timestamp, + }, + ) +} + +func (r *RabbitMQEventBus) Subscribe(ctx context.Context, eventType string, handler func(domain.Event) error) error { + // Declare a queue for this consumer + q, err := r.channel.QueueDeclare( + "", // name (empty = auto-generated) + false, // durable + true, // delete when unused + true, // exclusive + false, // no-wait + nil, // arguments + ) + if err != nil { + return err + } + + // Bind the queue to the exchange + err = r.channel.QueueBind( + q.Name, // queue name + "events."+eventType, // routing key + "events", // exchange + false, // no-wait + nil, // arguments + ) + if err != nil { + return err + } + + // Start consuming + msgs, err := r.channel.Consume( + q.Name, // queue + "", // consumer + false, // auto-ack + false, // exclusive + false, // no-local + false, // no-wait + nil, // args + ) + if err != nil { + return err + } + + go func() { + for { + select { + case <-ctx.Done(): + return + case msg, ok := <-msgs: + if !ok { + return + } + + var event domain.Event + if err := json.Unmarshal(msg.Body, &event); err != nil { + r.logger.Error("Failed to unmarshal event", "error", err) + msg.Nack(false, false) + continue + } + + if err := handler(event); err != nil { + r.logger.Error("Failed to handle event", "error", err) + msg.Nack(false, true) // requeue + } else { + msg.Ack(false) + } + } + } + }() + + return nil +} + +func (r *RabbitMQEventBus) Close() error { + if err := r.channel.Close(); err != nil { + return err + } + return r.conn.Close() +} diff --git a/backend/internal/event/projection.go b/backend/internal/event/projection.go new file mode 100644 index 0000000..a5d57f1 --- /dev/null +++ b/backend/internal/event/projection.go @@ -0,0 +1,171 @@ +// backend/internal/event/projection.go +package event + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/forfarm/backend/internal/domain" +) + +type FarmAnalyticsProjection struct { + eventSubscriber domain.EventSubscriber + repository domain.AnalyticsRepository + logger *slog.Logger +} + +func NewFarmAnalyticsProjection( + subscriber domain.EventSubscriber, + repository domain.AnalyticsRepository, + logger *slog.Logger, +) *FarmAnalyticsProjection { + if logger == nil { + logger = slog.Default() + } + return &FarmAnalyticsProjection{ + eventSubscriber: subscriber, + repository: repository, + logger: logger, + } +} + +func (p *FarmAnalyticsProjection) Start(ctx context.Context) error { + eventTypes := []string{ + "farm.created", "farm.updated", "farm.deleted", // Farm lifecycle + "weather.updated", // Weather updates + "cropland.created", "cropland.updated", "cropland.deleted", // Crop changes trigger count recalc + "inventory.item.created", "inventory.item.updated", "inventory.item.deleted", // Inventory changes trigger timestamp update + // Add other events that might influence FarmAnalytics, e.g., "pest.detected", "yield.recorded" + } + + p.logger.Info("FarmAnalyticsProjection starting, subscribing to events", "types", eventTypes) + + var errs []error + for _, eventType := range eventTypes { + if err := p.eventSubscriber.Subscribe(ctx, eventType, p.handleEvent); err != nil { + 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)) + // TODO: Decide if we should continue subscribing or fail hard + // return errors.Join(errs...) // Fail hard + } else { + p.logger.Info("Successfully subscribed to event type", "type", eventType) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + p.logger.Info("FarmAnalyticsProjection started successfully") + return nil +} + +func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // 10-second timeout + defer cancel() + + 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 + + // Special case: inventory events might use UserID as AggregateID. + // Need a way to map UserID to FarmID if necessary, or adjust event publishing. + // 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{}) + if ok { + if idVal, ok := payloadMap["farm_id"].(string); ok && idVal != "" { + farmID = idVal + } else if idVal, ok := payloadMap["user_id"].(string); ok && idVal != "" { + // !! WARNING: Need mapping from user_id to farm_id here !! + // This is a temp - requires adding userRepo or similar lookup + 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 + return nil + } + } + } + + if farmID == "" && event.Type != "farm.deleted" { // farm.deleted uses AggregateID which is the farmID being deleted + p.logger.Error("Cannot process event, missing farm_id", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID) + return nil + } + + var err error + switch event.Type { + case "farm.created", "farm.updated": + // Need to get the full Farm domain object from the payload + var farmData domain.Farm + jsonData, _ := json.Marshal(event.Payload) // Convert payload map back to JSON + 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) + // Nack or Ack based on error strategy? Ack for now. + return nil + } + // Ensure UUID is set from AggregateID if missing in payload itself + if farmData.UUID == "" { + farmData.UUID = event.AggregateID + } + err = p.repository.CreateOrUpdateFarmBaseData(ctx, &farmData) + + case "farm.deleted": + farmID = event.AggregateID // Use AggregateID directly for delete + if farmID == "" { + p.logger.Error("Cannot process farm.deleted event, missing farm_id in AggregateID", "event_id", event.ID) + return nil + } + err = p.repository.DeleteFarmAnalytics(ctx, farmID) + + case "weather.updated": + // Extract weather data from payload + var weatherData domain.WeatherData + jsonData, _ := json.Marshal(event.Payload) + 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) + return nil // Acknowledge bad data + } + err = p.repository.UpdateFarmAnalyticsWeather(ctx, farmID, &weatherData) + + case "cropland.created", "cropland.updated", "cropland.deleted": + payloadMap, ok := event.Payload.(map[string]interface{}) + if !ok { + p.logger.Error("Failed to cast cropland event payload to map", "event_id", event.ID) + return nil + } + idVal, ok := payloadMap["farm_id"].(string) + if !ok || idVal == "" { + p.logger.Error("Missing farm_id in cropland event payload", "event_id", event.ID, "event_type", event.Type) + return nil + } + farmID = idVal + err = p.repository.UpdateFarmAnalyticsCropStats(ctx, farmID) + + 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 != "" { + err = p.repository.UpdateFarmAnalyticsInventoryStats(ctx, farmID) + } else { + p.logger.Warn("Skipping inventory stats update due to missing farm_id", "event_id", event.ID) + return nil + } + + default: + p.logger.Warn("Received unhandled event type", "type", event.Type, "event_id", event.ID) + return nil + } + + if err != nil { + 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 + } + + p.logger.Debug("Successfully processed event and updated farm analytics", "event_type", event.Type, "farm_id", farmID) + return nil +} diff --git a/backend/internal/repository/postgres_cropland.go b/backend/internal/repository/postgres_cropland.go index 5eb0ff7..7f6e3a7 100644 --- a/backend/internal/repository/postgres_cropland.go +++ b/backend/internal/repository/postgres_cropland.go @@ -2,7 +2,10 @@ package repository import ( "context" + "encoding/json" + "fmt" "strings" + "time" "github.com/google/uuid" @@ -10,34 +13,31 @@ import ( ) type postgresCroplandRepository struct { - conn Connection + conn Connection + eventPublisher domain.EventPublisher } func NewPostgresCropland(conn Connection) domain.CroplandRepository { return &postgresCroplandRepository{conn: conn} } +func (p *postgresCroplandRepository) SetEventPublisher(publisher domain.EventPublisher) { + p.eventPublisher = publisher +} + func (p *postgresCroplandRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Cropland, error) { rows, err := p.conn.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() - var croplands []domain.Cropland for rows.Next() { var c domain.Cropland if err := rows.Scan( - &c.UUID, - &c.Name, - &c.Status, - &c.Priority, - &c.LandSize, - &c.GrowthStage, - &c.PlantID, - &c.FarmID, - &c.CreatedAt, - &c.UpdatedAt, + &c.UUID, &c.Name, &c.Status, &c.Priority, &c.LandSize, + &c.GrowthStage, &c.PlantID, &c.FarmID, &c.GeoFeature, + &c.CreatedAt, &c.UpdatedAt, ); err != nil { return nil, err } @@ -46,9 +46,17 @@ func (p *postgresCroplandRepository) fetch(ctx context.Context, query string, ar return croplands, nil } +func (p *postgresCroplandRepository) GetAll(ctx context.Context) ([]domain.Cropland, error) { + query := ` + SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, geo_feature, created_at, updated_at + FROM croplands` + + return p.fetch(ctx, query) +} + func (p *postgresCroplandRepository) GetByID(ctx context.Context, uuid string) (domain.Cropland, error) { query := ` - SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at + SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, geo_feature, created_at, updated_at FROM croplands WHERE uuid = $1` @@ -64,7 +72,7 @@ func (p *postgresCroplandRepository) GetByID(ctx context.Context, uuid string) ( func (p *postgresCroplandRepository) GetByFarmID(ctx context.Context, farmID string) ([]domain.Cropland, error) { query := ` - SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at + SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, geo_feature, created_at, updated_at FROM croplands WHERE farm_id = $1` @@ -72,47 +80,119 @@ func (p *postgresCroplandRepository) GetByFarmID(ctx context.Context, farmID str } func (p *postgresCroplandRepository) CreateOrUpdate(ctx context.Context, c *domain.Cropland) error { + isNew := false if strings.TrimSpace(c.UUID) == "" { - c.UUID = uuid.New().String() + c.UUID = uuid.NewString() + isNew = true } - query := ` - INSERT INTO croplands (uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) - ON CONFLICT (uuid) DO UPDATE - SET name = EXCLUDED.name, - status = EXCLUDED.status, - priority = EXCLUDED.priority, - land_size = EXCLUDED.land_size, - growth_stage = EXCLUDED.growth_stage, - plant_id = EXCLUDED.plant_id, - farm_id = EXCLUDED.farm_id, - updated_at = NOW() - RETURNING uuid, created_at, updated_at` + if c.GeoFeature != nil && len(c.GeoFeature) == 0 { + c.GeoFeature = nil + } - return p.conn.QueryRow( - ctx, - query, - c.UUID, - c.Name, - c.Status, - c.Priority, - c.LandSize, - c.GrowthStage, - c.PlantID, - c.FarmID, - ).Scan(&c.UUID, &c.CreatedAt, &c.UpdatedAt) // Fixed Scan call + query := ` + INSERT INTO croplands ( + uuid, name, status, priority, land_size, growth_stage, + plant_id, farm_id, geo_feature, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) + ON CONFLICT (uuid) DO UPDATE + SET name = EXCLUDED.name, status = EXCLUDED.status, priority = EXCLUDED.priority, + land_size = EXCLUDED.land_size, growth_stage = EXCLUDED.growth_stage, + plant_id = EXCLUDED.plant_id, farm_id = EXCLUDED.farm_id, + geo_feature = EXCLUDED.geo_feature, updated_at = NOW() + RETURNING uuid, created_at, updated_at` + + err := p.conn.QueryRow( + ctx, query, + c.UUID, c.Name, c.Status, c.Priority, c.LandSize, c.GrowthStage, + c.PlantID, c.FarmID, c.GeoFeature, + ).Scan(&c.UUID, &c.CreatedAt, &c.UpdatedAt) + + if err != nil { + return err + } + + if p.eventPublisher != nil { + eventType := "cropland.updated" + if isNew { + eventType = "cropland.created" + } + + // Avoid sending raw json.RawMessage directly if possible + var geoFeatureMap interface{} + if c.GeoFeature != nil { + _ = json.Unmarshal(c.GeoFeature, &geoFeatureMap) + } + + payload := map[string]interface{}{ + "crop_id": c.UUID, + "name": c.Name, + "status": c.Status, + "priority": c.Priority, + "land_size": c.LandSize, + "growth_stage": c.GrowthStage, + "plant_id": c.PlantID, + "farm_id": c.FarmID, + "geo_feature": geoFeatureMap, + "created_at": c.CreatedAt, + "updated_at": c.UpdatedAt, + "event_type": eventType, + } + + event := domain.Event{ + ID: uuid.NewString(), + Type: eventType, + Source: "cropland-repository", + Timestamp: time.Now().UTC(), + AggregateID: c.UUID, + Payload: payload, + } + go func() { + bgCtx := context.Background() + if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil { + fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) // Replace with proper logging + } + }() + } + + return nil } + func (p *postgresCroplandRepository) Delete(ctx context.Context, uuid string) error { + // Optional: Fetch details before deleting if needed for event payload + // cropland, err := p.GetByID(ctx, uuid) // Might fail if already deleted, handle carefully + // if err != nil && !errors.Is(err, domain.ErrNotFound){ return err } // Return actual errors + query := `DELETE FROM croplands WHERE uuid = $1` _, err := p.conn.Exec(ctx, query, uuid) - return err -} + if err != nil { + return err + } -func (p *postgresCroplandRepository) GetAll(ctx context.Context) ([]domain.Cropland, error) { - query := ` - SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at - FROM croplands` + if p.eventPublisher != nil { + eventType := "cropland.deleted" + payload := map[string]interface{}{ + "crop_id": uuid, + // Include farm_id if easily available or fetched before delete + // "farm_id": cropland.FarmID + "event_type": eventType, + } + event := domain.Event{ + ID: uuid, + Type: eventType, + Source: "cropland-repository", + Timestamp: time.Now().UTC(), + AggregateID: uuid, + Payload: payload, + } + go func() { + bgCtx := context.Background() + if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil { + fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) + } + }() + } - return p.fetch(ctx, query) + return nil } diff --git a/backend/internal/repository/postgres_farm.go b/backend/internal/repository/postgres_farm.go index 5cd9439..ba72476 100644 --- a/backend/internal/repository/postgres_farm.go +++ b/backend/internal/repository/postgres_farm.go @@ -3,19 +3,25 @@ package repository import ( "context" "strings" + "time" "github.com/forfarm/backend/internal/domain" "github.com/google/uuid" ) type postgresFarmRepository struct { - conn Connection + conn Connection + eventPublisher domain.EventPublisher } func NewPostgresFarm(conn Connection) domain.FarmRepository { return &postgresFarmRepository{conn: conn} } +func (p *postgresFarmRepository) SetEventPublisher(publisher domain.EventPublisher) { + p.eventPublisher = publisher +} + func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Farm, error) { rows, err := p.conn.Query(ctx, query, args...) if err != nil { @@ -26,74 +32,186 @@ func (p *postgresFarmRepository) fetch(ctx context.Context, query string, args . var farms []domain.Farm for rows.Next() { var f domain.Farm + // Order: uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id if err := rows.Scan( &f.UUID, &f.Name, &f.Lat, &f.Lon, + &f.FarmType, + &f.TotalSize, &f.CreatedAt, &f.UpdatedAt, &f.OwnerID, ); err != nil { return nil, err } - farms = append(farms, f) } return farms, nil } -func (p *postgresFarmRepository) GetByID(ctx context.Context, uuid string) (domain.Farm, error) { +func (p *postgresFarmRepository) fetchCroplandsByFarmIDs(ctx context.Context, farmIDs []string) (map[string][]domain.Cropland, error) { + if len(farmIDs) == 0 { + return make(map[string][]domain.Cropland), nil + } + query := ` - SELECT uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types + SELECT uuid, name, status, priority, land_size, growth_stage, plant_id, farm_id, created_at, updated_at + FROM croplands + WHERE farm_id = ANY($1)` + + rows, err := p.conn.Query(ctx, query, farmIDs) + if err != nil { + return nil, err + } + defer rows.Close() + + croplandsByFarmID := make(map[string][]domain.Cropland) + for rows.Next() { + var c domain.Cropland + if err := rows.Scan( + &c.UUID, + &c.Name, + &c.Status, + &c.Priority, + &c.LandSize, + &c.GrowthStage, + &c.PlantID, + &c.FarmID, + &c.CreatedAt, + &c.UpdatedAt, + ); err != nil { + return nil, err + } + + croplandsByFarmID[c.FarmID] = append(croplandsByFarmID[c.FarmID], c) + } + if err = rows.Err(); err != nil { + return nil, err + } + + return croplandsByFarmID, nil +} + +func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*domain.Farm, error) { + query := ` + SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id FROM farms WHERE uuid = $1` - - farms, err := p.fetch(ctx, query, uuid) + var f domain.Farm + err := p.conn.QueryRow(ctx, query, farmId).Scan( + &f.UUID, + &f.Name, + &f.Lat, + &f.Lon, + &f.FarmType, + &f.TotalSize, + &f.CreatedAt, + &f.UpdatedAt, + &f.OwnerID, + ) if err != nil { - return domain.Farm{}, err + return nil, err } - if len(farms) == 0 { - return domain.Farm{}, domain.ErrNotFound - } - return farms[0], nil + return &f, nil } func (p *postgresFarmRepository) GetByOwnerID(ctx context.Context, ownerID string) ([]domain.Farm, error) { query := ` - SELECT uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types - FROM farms + SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id + FROM farms WHERE owner_id = $1` - return p.fetch(ctx, query, ownerID) + farms, err := p.fetch(ctx, query, ownerID) + if err != nil { + return nil, err + } + if len(farms) == 0 { + return []domain.Farm{}, nil + } + + 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 { + println("Warning: Failed to fetch croplands for farms:", err.Error()) + return farms, nil + } + + for farmID, croplands := range croplandsByFarmID { + if farm, ok := farmMap[farmID]; ok { + farm.Crops = croplands + } + } + + return farms, nil } func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.Farm) error { - if strings.TrimSpace(f.UUID) == "" { + isNew := strings.TrimSpace(f.UUID) == "" + + if isNew { f.UUID = uuid.New().String() } - query := ` - INSERT INTO farms (uuid, name, lat, lon, created_at, updated_at, owner_id, plant_types) - VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6) + query := ` + INSERT INTO farms (uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7) ON CONFLICT (uuid) DO UPDATE SET name = EXCLUDED.name, lat = EXCLUDED.lat, lon = EXCLUDED.lon, + farm_type = EXCLUDED.farm_type, + total_size = EXCLUDED.total_size, updated_at = NOW(), - owner_id = EXCLUDED.owner_id, - plant_types = EXCLUDED.plant_types + owner_id = EXCLUDED.owner_id RETURNING uuid, created_at, updated_at` + err := p.conn.QueryRow(ctx, query, f.UUID, f.Name, f.Lat, f.Lon, f.FarmType, f.TotalSize, f.OwnerID). + Scan(&f.UUID, &f.CreatedAt, &f.UpdatedAt) - return p.conn.QueryRow( - ctx, - query, - f.UUID, - f.Name, - f.Lat, - f.Lon, - f.OwnerID, - ).Scan(&f.UUID, &f.CreatedAt, &f.UpdatedAt) + if err != nil { + return err + } + + if p.eventPublisher != nil { + eventType := "farm.updated" + if isNew { + eventType = "farm.created" + } + + event := domain.Event{ + ID: uuid.New().String(), + Type: eventType, + Source: "farm-repository", + Timestamp: time.Now(), + AggregateID: f.UUID, + Payload: map[string]interface{}{ + "farm_id": f.UUID, + "name": f.Name, + "location": map[string]float64{"lat": f.Lat, "lon": f.Lon}, + "farm_type": f.FarmType, + "total_size": f.TotalSize, + "owner_id": f.OwnerID, + "created_at": f.CreatedAt, + "updated_at": f.UpdatedAt, + }, + } + + go func() { + bgCtx := context.Background() + if err := p.eventPublisher.Publish(bgCtx, event); err != nil { + println("Failed to publish event", err.Error()) + } + }() + } + + return nil } func (p *postgresFarmRepository) Delete(ctx context.Context, uuid string) error { diff --git a/backend/internal/repository/postgres_farm_analytics.go b/backend/internal/repository/postgres_farm_analytics.go new file mode 100644 index 0000000..4b234e5 --- /dev/null +++ b/backend/internal/repository/postgres_farm_analytics.go @@ -0,0 +1,452 @@ +// 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 +} diff --git a/backend/internal/repository/postgres_inventory.go b/backend/internal/repository/postgres_inventory.go index 1bd5d98..c0e1287 100644 --- a/backend/internal/repository/postgres_inventory.go +++ b/backend/internal/repository/postgres_inventory.go @@ -7,14 +7,16 @@ import ( "time" "github.com/forfarm/backend/internal/domain" + "github.com/google/uuid" ) type postgresInventoryRepository struct { - conn Connection + conn Connection + eventPublisher domain.EventPublisher } -func NewPostgresInventory(conn Connection) domain.InventoryRepository { - return &postgresInventoryRepository{conn: conn} +func NewPostgresInventory(conn Connection, publisher domain.EventPublisher) domain.InventoryRepository { + return &postgresInventoryRepository{conn: conn, eventPublisher: publisher} } func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.InventoryItem, error) { @@ -219,60 +221,124 @@ func (p *postgresInventoryRepository) GetAll(ctx context.Context) ([]domain.Inve func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *domain.InventoryItem) error { now := time.Now() item.UpdatedAt = now + isNew := false if item.ID == "" { + isNew = true item.CreatedAt = now - query := ` - INSERT INTO inventory_items - (id, user_id, name, category_id, quantity, unit_id, date_added, status_id, created_at, updated_at) + query := ` + INSERT INTO inventory_items + (id, user_id, name, category_id, quantity, unit_id, date_added, status_id, created_at, updated_at) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id` - return p.conn.QueryRow( + err := p.conn.QueryRow( ctx, query, - item.UserID, - item.Name, - item.CategoryID, - item.Quantity, - item.UnitID, - item.DateAdded, - item.StatusID, - item.CreatedAt, - item.UpdatedAt, + item.UserID, item.Name, item.CategoryID, item.Quantity, + item.UnitID, item.DateAdded, item.StatusID, item.CreatedAt, item.UpdatedAt, ).Scan(&item.ID) + if err != nil { + // Log error + return err + } + } else { + query := ` + UPDATE inventory_items + SET name = $1, category_id = $2, quantity = $3, unit_id = $4, + date_added = $5, status_id = $6, updated_at = $7 + WHERE id = $8 AND user_id = $9 + RETURNING id` // Ensure RETURNING id exists or handle differently + err := p.conn.QueryRow( + ctx, + query, + item.Name, item.CategoryID, item.Quantity, item.UnitID, + item.DateAdded, item.StatusID, item.UpdatedAt, item.ID, item.UserID, + ).Scan(&item.ID) // Scan to confirm update happened or handle potential ErrNoRows + if err != nil { + // Log error + return err + } } - query := ` - UPDATE inventory_items - SET name = $1, - category_id = $2, - quantity = $3, - unit_id = $4, - date_added = $5, - status_id = $6, - updated_at = $7 - WHERE id = $8 AND user_id = $9 - RETURNING id` + // --- Publish Event --- + if p.eventPublisher != nil { + eventType := "inventory.item.updated" + if isNew { + eventType = "inventory.item.created" + } - return p.conn.QueryRow( - ctx, - query, - item.Name, - item.CategoryID, - item.Quantity, - item.UnitID, - item.DateAdded, - item.StatusID, - item.UpdatedAt, - item.ID, - item.UserID, - ).Scan(&item.ID) + payload := map[string]interface{}{ + "item_id": item.ID, + "user_id": item.UserID, // Include user ID for potential farm lookup in projection + "name": item.Name, + "category_id": item.CategoryID, + "quantity": item.Quantity, + "unit_id": item.UnitID, + "status_id": item.StatusID, + "date_added": item.DateAdded, + "updated_at": item.UpdatedAt, + // NO farm_id easily available here without extra lookup + } + + event := domain.Event{ + ID: uuid.NewString(), + Type: eventType, + Source: "inventory-repository", + Timestamp: time.Now().UTC(), + AggregateID: item.UserID, // Use UserID as AggregateID for inventory? Or item.ID? Let's use item.ID. + Payload: payload, + } + // Use AggregateID = item.ID for consistency if item is the aggregate root + event.AggregateID = item.ID + + go func() { + bgCtx := context.Background() + if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil { + fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) // Use proper logging + } + }() + } + // --- End Publish Event --- + + return nil } func (p *postgresInventoryRepository) Delete(ctx context.Context, id, userID string) error { query := `DELETE FROM inventory_items WHERE id = $1 AND user_id = $2` - _, err := p.conn.Exec(ctx, query, id, userID) - return err + cmdTag, err := p.conn.Exec(ctx, query, id, userID) + if err != nil { + // Log error + return err + } + if cmdTag.RowsAffected() == 0 { + return domain.ErrNotFound // Or a permission error if user doesn't match + } + + // --- Publish Event --- + if p.eventPublisher != nil { + eventType := "inventory.item.deleted" + payload := map[string]interface{}{ + "item_id": id, + "user_id": userID, // Include user ID + } + event := domain.Event{ + ID: uuid.NewString(), + Type: eventType, + Source: "inventory-repository", + Timestamp: time.Now().UTC(), + AggregateID: id, // Use item ID as aggregate ID + Payload: payload, + } + go func() { + bgCtx := context.Background() + if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil { + fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) // Use proper logging + } + }() + } + // --- End Publish Event --- + + return nil } func (p *postgresInventoryRepository) GetStatuses(ctx context.Context) ([]domain.InventoryStatus, error) { 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/backend/internal/services/weather/cached_fetcher.go b/backend/internal/services/weather/cached_fetcher.go new file mode 100644 index 0000000..e757fd1 --- /dev/null +++ b/backend/internal/services/weather/cached_fetcher.go @@ -0,0 +1,52 @@ +package weather + +import ( + "context" + "fmt" + "time" + + "log/slog" + + "github.com/forfarm/backend/internal/domain" + "github.com/patrickmn/go-cache" +) + +type CachedWeatherFetcher struct { + next domain.WeatherFetcher + cache *cache.Cache + logger *slog.Logger +} + +func NewCachedWeatherFetcher(fetcher domain.WeatherFetcher, ttl time.Duration, cleanupInterval time.Duration, logger *slog.Logger) domain.WeatherFetcher { + c := cache.New(ttl, cleanupInterval) + return &CachedWeatherFetcher{ + next: fetcher, + cache: c, + logger: logger, + } +} + +func (f *CachedWeatherFetcher) GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*domain.WeatherData, error) { + cacheKey := fmt.Sprintf("weather_coords_%.4f_%.4f", lat, lon) + + if data, found := f.cache.Get(cacheKey); found { + if weatherData, ok := data.(*domain.WeatherData); ok { + return weatherData, nil + } + f.logger.Warn("Invalid data type found in weather cache", "key", cacheKey) + } + + f.logger.Debug("Cache miss for weather data", "key", cacheKey) + + weatherData, err := f.next.GetCurrentWeatherByCoords(ctx, lat, lon) + if err != nil { + return nil, err + } + + if weatherData != nil { + f.cache.Set(cacheKey, weatherData, cache.DefaultExpiration) // Uses the TTL set during cache creation + f.logger.Debug("Stored fetched weather data in cache", "key", cacheKey) + } + + return weatherData, nil +} diff --git a/backend/internal/services/weather/openweathermap_fetcher.go b/backend/internal/services/weather/openweathermap_fetcher.go new file mode 100644 index 0000000..6b5b676 --- /dev/null +++ b/backend/internal/services/weather/openweathermap_fetcher.go @@ -0,0 +1,154 @@ +// backend/internal/services/weather/openweathermap_fetcher.go +package weather + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "time" + + "github.com/forfarm/backend/internal/domain" +) + +const openWeatherMapOneCallAPIURL = "https://api.openweathermap.org/data/3.0/onecall" + +type openWeatherMapOneCallResponse struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Timezone string `json:"timezone"` + TimezoneOffset int `json:"timezone_offset"` + 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 { + ID int `json:"id"` + Main string `json:"main"` + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + } `json:"current,omitempty"` + // Minutely []... + // Hourly []... + // Daily []... + // Alerts []... +} + +type OpenWeatherMapFetcher struct { + apiKey string + client *http.Client + logger *slog.Logger +} + +func NewOpenWeatherMapFetcher(apiKey string, client *http.Client, logger *slog.Logger) domain.WeatherFetcher { + if client == nil { + client = &http.Client{Timeout: 10 * time.Second} + } + if logger == nil { + logger = slog.Default() + } + return &OpenWeatherMapFetcher{ + apiKey: apiKey, + client: client, + logger: logger, + } +} + +func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, lat, lon float64) (*domain.WeatherData, error) { + queryParams := url.Values{} + queryParams.Set("lat", fmt.Sprintf("%.4f", lat)) + queryParams.Set("lon", fmt.Sprintf("%.4f", lon)) + queryParams.Set("appid", f.apiKey) + queryParams.Set("units", "metric") // Request Celsius and m/s + queryParams.Set("exclude", "minutely,hourly,daily,alerts") // Exclude parts we don't need now + + fullURL := fmt.Sprintf("%s?%s", openWeatherMapOneCallAPIURL, queryParams.Encode()) + f.logger.Debug("Fetching weather from OpenWeatherMap OneCall API", "url", fullURL) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) + if err != nil { + f.logger.Error("Failed to create OpenWeatherMap request", "error", err) + return nil, fmt.Errorf("failed to create weather request: %w", err) + } + + resp, err := f.client.Do(req) + if err != nil { + f.logger.Error("Failed to execute OpenWeatherMap request", "url", fullURL, "error", err) + return nil, fmt.Errorf("failed to fetch weather data: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + // TODO: Read resp.Body to get error message from OpenWeatherMap + bodyBytes, _ := io.ReadAll(resp.Body) + f.logger.Error("OpenWeatherMap API returned non-OK status", + "url", fullURL, + "status_code", resp.StatusCode, + "body", string(bodyBytes)) + return nil, fmt.Errorf("weather API request failed with status: %s", resp.Status) + } + + var owmResp openWeatherMapOneCallResponse + if err := json.NewDecoder(resp.Body).Decode(&owmResp); err != nil { + f.logger.Error("Failed to decode OpenWeatherMap OneCall response", "error", err) + return nil, fmt.Errorf("failed to decode weather response: %w", err) + } + + if owmResp.Current == nil { + 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 { + f.logger.Warn("OpenWeatherMap response missing weather description details", "lat", lat, "lon", lon) + return nil, fmt.Errorf("weather data description not found in response") + } + + // Create domain object using pointers for optional fields + weatherData := &domain.WeatherData{} // Initialize empty struct first + + // Assign values using pointers, checking for nil where appropriate + weatherData.TempCelsius = ¤t.Temp + humidityFloat := float64(current.Humidity) + weatherData.Humidity = &humidityFloat + weatherData.Description = ¤t.Weather[0].Description + weatherData.Icon = ¤t.Weather[0].Icon + weatherData.WindSpeed = ¤t.WindSpeed + if current.Rain != nil { + weatherData.RainVolume1h = ¤t.Rain.OneH + } + observedTime := time.Unix(current.Dt, 0).UTC() + weatherData.ObservedAt = &observedTime + now := time.Now().UTC() + weatherData.WeatherLastUpdated = &now + + f.logger.Debug("Successfully fetched weather data", + "lat", lat, + "lon", lon, + "temp", *weatherData.TempCelsius, + "description", *weatherData.Description) + + return weatherData, nil +} diff --git a/backend/internal/workers/weather_updater.go b/backend/internal/workers/weather_updater.go new file mode 100644 index 0000000..1b48880 --- /dev/null +++ b/backend/internal/workers/weather_updater.go @@ -0,0 +1,170 @@ +// backend/internal/workers/weather_updater.go +package workers + +import ( + "context" + "log/slog" + "sync" + "time" + + "github.com/forfarm/backend/internal/domain" + "github.com/google/uuid" +) + +type WeatherUpdater struct { + farmRepo domain.FarmRepository + weatherFetcher domain.WeatherFetcher + eventPublisher domain.EventPublisher + logger *slog.Logger + fetchInterval time.Duration + stopChan chan struct{} // Channel to signal stopping + wg sync.WaitGroup +} + +func NewWeatherUpdater( + farmRepo domain.FarmRepository, + weatherFetcher domain.WeatherFetcher, + eventPublisher domain.EventPublisher, + logger *slog.Logger, + fetchInterval time.Duration, +) *WeatherUpdater { + if logger == nil { + logger = slog.Default() + } + if fetchInterval <= 0 { + fetchInterval = 15 * time.Minute + } + return &WeatherUpdater{ + farmRepo: farmRepo, + weatherFetcher: weatherFetcher, + eventPublisher: eventPublisher, + logger: logger, + fetchInterval: fetchInterval, + stopChan: make(chan struct{}), + } +} + +func (w *WeatherUpdater) Start(ctx context.Context) { + w.logger.Info("Starting Weather Updater worker", "interval", w.fetchInterval) + ticker := time.NewTicker(w.fetchInterval) + + w.wg.Add(1) + + go func() { + defer w.wg.Done() + defer ticker.Stop() + w.logger.Info("Weather Updater goroutine started") + + w.fetchAndUpdateAllFarms(ctx) + + for { + select { + case <-ticker.C: + w.logger.Info("Weather Updater tick: fetching weather data") + w.fetchAndUpdateAllFarms(ctx) + case <-w.stopChan: + w.logger.Info("Weather Updater received stop signal, stopping...") + return + case <-ctx.Done(): + w.logger.Info("Weather Updater context cancelled, stopping...", "reason", ctx.Err()) + return + } + } + }() +} + +func (w *WeatherUpdater) Stop() { + w.logger.Info("Attempting to stop Weather Updater worker...") + close(w.stopChan) + w.wg.Wait() + w.logger.Info("Weather Updater worker stopped") +} + +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) // Example timeout + // defer cancel() + + // TODO: Need a GetAllFarms method in the FarmRepository or a way to efficiently get all farm locations. + farms, err := w.farmRepo.GetByOwnerID(ctx, "") // !! REPLACE with a proper GetAll method !! + if err != nil { + w.logger.Error("Failed to get farms for weather update", "error", err) + return + } + if len(farms) == 0 { + w.logger.Info("No farms found to update weather for.") + return + } + + w.logger.Info("Found farms for weather update", "count", len(farms)) + + var fetchWg sync.WaitGroup + fetchCtx, cancelFetches := context.WithCancel(ctx) + defer cancelFetches() + + for _, farm := range farms { + if farm.Lat == 0 && farm.Lon == 0 { + w.logger.Warn("Skipping farm with zero coordinates", "farm_id", farm.UUID, "farm_name", farm.Name) + continue + } + + fetchWg.Add(1) + go func(f domain.Farm) { + defer fetchWg.Done() + select { + case <-fetchCtx.Done(): + return + default: + w.fetchAndPublishWeather(fetchCtx, f) + } + }(farm) + } + + fetchWg.Wait() + w.logger.Info("Finished weather fetch cycle for farms", "count", len(farms)) +} + +func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain.Farm) { + weatherData, err := w.weatherFetcher.GetCurrentWeatherByCoords(ctx, farm.Lat, farm.Lon) + if err != nil { + w.logger.Error("Failed to fetch weather data", "farm_id", farm.UUID, "lat", farm.Lat, "lon", farm.Lon, "error", err) + return + } + if weatherData == nil { + w.logger.Warn("Received nil weather data without error", "farm_id", farm.UUID) + return + } + + payloadMap := map[string]interface{}{ + "farm_id": farm.UUID, + "lat": farm.Lat, + "lon": farm.Lon, + "temp_celsius": weatherData.TempCelsius, + "humidity": weatherData.Humidity, + "description": weatherData.Description, + "icon": weatherData.Icon, + "wind_speed": weatherData.WindSpeed, + "rain_volume_1h": weatherData.RainVolume1h, + "observed_at": weatherData.ObservedAt, + "weather_last_updated": weatherData.WeatherLastUpdated, + } + + event := domain.Event{ + ID: uuid.NewString(), + Type: "weather.updated", + Source: "weather-updater-worker", + Timestamp: time.Now().UTC(), + AggregateID: farm.UUID, + Payload: payloadMap, + } + + pubCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err = w.eventPublisher.Publish(pubCtx, event) + if err != nil { + w.logger.Error("Failed to publish weather.updated event", "farm_id", farm.UUID, "event_id", event.ID, "error", err) + } else { + w.logger.Debug("Published weather.updated event", "farm_id", farm.UUID, "event_id", event.ID) + } +} diff --git a/backend/makefile b/backend/makefile index 49fbb06..dd79ec7 100644 --- a/backend/makefile +++ b/backend/makefile @@ -7,4 +7,7 @@ run: go run cmd/forfarm/main.go API migrate: - go run cmd/forfarm/main.go migrate \ No newline at end of file + go run cmd/forfarm/main.go migrate + +rollback: + go run cmd/forfarm/main.go rollback $(VERSION) \ No newline at end of file diff --git a/backend/migrations/00001_create_user_table.sql b/backend/migrations/00001_create_user_table.sql index 09d2932..6567cab 100644 --- a/backend/migrations/00001_create_user_table.sql +++ b/backend/migrations/00001_create_user_table.sql @@ -10,4 +10,10 @@ CREATE TABLE users ( is_active BOOLEAN NOT NULL DEFAULT TRUE ); -CREATE UNIQUE INDEX idx_users_uuid ON users(uuid); \ No newline at end of file +CREATE UNIQUE INDEX idx_users_uuid ON users(uuid); +CREATE UNIQUE INDEX idx_users_email ON users(email); -- Added unique constraint for email + +-- +goose Down +DROP INDEX IF EXISTS idx_users_email; +DROP INDEX IF EXISTS idx_users_uuid; +DROP TABLE IF EXISTS users; \ No newline at end of file diff --git a/backend/migrations/00002_create_farm_and_cropland_tables.sql b/backend/migrations/00002_create_farm_and_cropland_tables.sql index 0dbd026..0896df4 100644 --- a/backend/migrations/00002_create_farm_and_cropland_tables.sql +++ b/backend/migrations/00002_create_farm_and_cropland_tables.sql @@ -12,7 +12,7 @@ CREATE TABLE soil_conditions ( CREATE TABLE harvest_units ( id SERIAL PRIMARY KEY, name TEXT NOT NULL UNIQUE -); +); CREATE TABLE plants ( uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -45,7 +45,7 @@ CREATE TABLE farms ( name TEXT NOT NULL, lat DOUBLE PRECISION[] NOT NULL, lon DOUBLE PRECISION[] NOT NULL, - plant_types UUID[], + plant_types UUID[], -- This column will be dropped in the next migration created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), owner_id UUID NOT NULL, @@ -55,10 +55,10 @@ CREATE TABLE farms ( CREATE TABLE croplands ( uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, - status TEXT NOT NULL, + status TEXT NOT NULL, -- Consider creating a status table if values are fixed priority INT NOT NULL, land_size DOUBLE PRECISION NOT NULL, - growth_stage TEXT NOT NULL, + growth_stage TEXT NOT NULL, -- Consider creating a growth_stage table plant_id UUID NOT NULL, farm_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -66,3 +66,12 @@ CREATE TABLE croplands ( CONSTRAINT fk_cropland_farm FOREIGN KEY (farm_id) REFERENCES farms(uuid) ON DELETE CASCADE, CONSTRAINT fk_cropland_plant FOREIGN KEY (plant_id) REFERENCES plants(uuid) ON DELETE CASCADE ); + + +-- +goose Down +DROP TABLE IF EXISTS croplands; +DROP TABLE IF EXISTS farms; +DROP TABLE IF EXISTS plants; +DROP TABLE IF EXISTS harvest_units; +DROP TABLE IF EXISTS soil_conditions; +DROP TABLE IF EXISTS light_profiles; \ No newline at end of file diff --git a/backend/migrations/00003_drop_column_plant_types_from_farms.sql b/backend/migrations/00003_drop_column_plant_types_from_farms.sql index b643d83..9c61e4f 100644 --- a/backend/migrations/00003_drop_column_plant_types_from_farms.sql +++ b/backend/migrations/00003_drop_column_plant_types_from_farms.sql @@ -1,2 +1,8 @@ -- +goose Up -ALTER TABLE farms DROP COLUMN plant_types; \ No newline at end of file +-- This column was initially created in 00002 but deemed unnecessary. +ALTER TABLE farms DROP COLUMN IF EXISTS plant_types; -- Use IF EXISTS for safety + +-- +goose Down +-- Add the column back if rolling back. +ALTER TABLE farms + ADD COLUMN plant_types UUID[]; \ No newline at end of file diff --git a/backend/migrations/00004_create_inventory_items_table.sql b/backend/migrations/00004_create_inventory_items_table.sql index 905726f..1d50ea1 100644 --- a/backend/migrations/00004_create_inventory_items_table.sql +++ b/backend/migrations/00004_create_inventory_items_table.sql @@ -1,14 +1,16 @@ -- +goose Up +-- Creates the initial inventory_items table. +-- Note: 'category', 'type', 'unit', and 'status' columns will be modified/replaced by later migrations. CREATE TABLE inventory_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, name TEXT NOT NULL, - category TEXT NOT NULL, - type TEXT NOT NULL, + category TEXT NOT NULL, -- To be replaced by category_id + type TEXT NOT NULL, -- To be dropped quantity DOUBLE PRECISION NOT NULL, - unit TEXT NOT NULL, + unit TEXT NOT NULL, -- To be replaced by unit_id date_added TIMESTAMPTZ NOT NULL, - status TEXT NOT NULL, + status TEXT NOT NULL, -- To be replaced by status_id created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_inventory_items_user FOREIGN KEY (user_id) REFERENCES users(uuid) ON DELETE CASCADE @@ -18,3 +20,6 @@ CREATE TABLE inventory_items ( CREATE INDEX idx_inventory_items_user_id ON inventory_items(user_id); CREATE INDEX idx_inventory_items_user_category ON inventory_items(user_id, category); CREATE INDEX idx_inventory_items_user_status ON inventory_items(user_id, status); + +-- +goose Down +DROP TABLE IF EXISTS inventory_items; -- Indexes are dropped automatically \ No newline at end of file diff --git a/backend/migrations/00005_create_analytic_event_table.sql b/backend/migrations/00005_create_analytic_event_table.sql new file mode 100644 index 0000000..11386f9 --- /dev/null +++ b/backend/migrations/00005_create_analytic_event_table.sql @@ -0,0 +1,79 @@ +-- +goose Up +-- Create analytics_events table to store all events +CREATE TABLE IF NOT EXISTS public.analytics_events ( + id SERIAL PRIMARY KEY, + farm_id UUID NOT NULL, + event_type TEXT NOT NULL, + event_data JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_analytics_farm FOREIGN KEY (farm_id) REFERENCES public.farms(uuid) ON DELETE CASCADE +); + +-- Create index for faster queries +CREATE INDEX idx_analytics_events_farm_id ON public.analytics_events(farm_id); +CREATE INDEX idx_analytics_events_event_type ON public.analytics_events(event_type); +CREATE INDEX idx_analytics_events_created_at ON public.analytics_events(created_at); + +-- Create a simple materialized view for farm analytics (Version 1) +CREATE MATERIALIZED VIEW public.farm_analytics_view AS +SELECT + f.uuid AS farm_id, + f.name AS farm_name, + f.owner_id, + -- Columns added in migration 00006 will be added to the view later + -- f.farm_type, + -- f.total_size, + f.created_at, + f.updated_at, + COUNT(ae.id) AS total_events, + MAX(ae.created_at) AS last_event_at +FROM + public.farms f +LEFT JOIN + public.analytics_events ae ON f.uuid = ae.farm_id +GROUP BY + f.uuid, f.name, f.owner_id, f.created_at, f.updated_at; + +-- Create index for faster queries on the view +-- UNIQUE index is required for CONCURRENTLY refresh +CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id); +CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id); + +-- Create function to refresh the materialized view CONCURRENTLY +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION public.refresh_farm_analytics_view() +RETURNS TRIGGER AS $$ +BEGIN + -- Use CONCURRENTLY to avoid locking the view during refresh + REFRESH MATERIALIZED VIEW CONCURRENTLY public.farm_analytics_view; + RETURN NULL; -- result is ignored since this is an AFTER trigger +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- Create trigger to refresh the view when new events are added +CREATE TRIGGER refresh_farm_analytics_view_trigger_events +AFTER INSERT ON public.analytics_events +FOR EACH STATEMENT +EXECUTE FUNCTION public.refresh_farm_analytics_view(); + +-- Create trigger to refresh the view when farms are updated +-- Note: This trigger will need to be updated if the view definition changes significantly +CREATE TRIGGER refresh_farm_analytics_view_trigger_farms +AFTER INSERT OR UPDATE OR DELETE ON public.farms +FOR EACH STATEMENT +EXECUTE FUNCTION public.refresh_farm_analytics_view(); + +-- +goose Down +-- Drop triggers first +DROP TRIGGER IF EXISTS refresh_farm_analytics_view_trigger_events ON public.analytics_events; +DROP TRIGGER IF EXISTS refresh_farm_analytics_view_trigger_farms ON public.farms; + +-- Drop function +DROP FUNCTION IF EXISTS public.refresh_farm_analytics_view() CASCADE; + +-- Drop materialized view +DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view CASCADE; + +-- Drop table with CASCADE to ensure all dependencies are removed +DROP TABLE IF EXISTS public.analytics_events CASCADE; \ No newline at end of file diff --git a/backend/migrations/00005_create_inventory_status.sql b/backend/migrations/00005_create_inventory_status.sql deleted file mode 100644 index 4f634f2..0000000 --- a/backend/migrations/00005_create_inventory_status.sql +++ /dev/null @@ -1,5 +0,0 @@ --- +goose Up -CREATE TABLE inventory_status ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE -); diff --git a/backend/migrations/00006_modify_inventory_table.sql b/backend/migrations/00006_modify_inventory_table.sql deleted file mode 100644 index 44fb45c..0000000 --- a/backend/migrations/00006_modify_inventory_table.sql +++ /dev/null @@ -1,15 +0,0 @@ --- +goose Up -ALTER TABLE inventory_items -ADD COLUMN status_id INT; - -UPDATE inventory_items -SET status_id = (SELECT id FROM inventory_status WHERE name = inventory_items.status); - -ALTER TABLE inventory_items -DROP COLUMN status; - -ALTER TABLE inventory_items -ADD CONSTRAINT fk_inventory_items_status FOREIGN KEY (status_id) REFERENCES inventory_status(id) ON DELETE CASCADE; - -CREATE INDEX idx_inventory_items_status_id ON inventory_items(status_id); - diff --git a/backend/migrations/00006_update_farm_table.sql b/backend/migrations/00006_update_farm_table.sql new file mode 100644 index 0000000..acc1622 --- /dev/null +++ b/backend/migrations/00006_update_farm_table.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- Add new columns for farm details +ALTER TABLE farms + ADD COLUMN farm_type TEXT, + ADD COLUMN total_size TEXT; -- Consider NUMERIC or DOUBLE PRECISION if it's always a number + +-- Change lat/lon from array to single value +-- Assumes the first element of the array was the intended value +ALTER TABLE farms + ALTER COLUMN lat TYPE DOUBLE PRECISION USING lat[1], + ALTER COLUMN lon TYPE DOUBLE PRECISION USING lon[1]; + +-- Note: The farm_analytics_view created in 00005 does not yet include these new columns. +-- Subsequent migrations will update the view. + +-- +goose Down +-- Revert lat/lon change +ALTER TABLE farms + ALTER COLUMN lat TYPE DOUBLE PRECISION[] USING ARRAY[lat], + ALTER COLUMN lon TYPE DOUBLE PRECISION[] USING ARRAY[lon]; + +-- Remove added columns +ALTER TABLE farms + DROP COLUMN IF EXISTS farm_type, + DROP COLUMN IF EXISTS total_size; \ No newline at end of file diff --git a/backend/migrations/00007_create_inventory_status.sql b/backend/migrations/00007_create_inventory_status.sql index 7e54cc9..14ea076 100644 --- a/backend/migrations/00007_create_inventory_status.sql +++ b/backend/migrations/00007_create_inventory_status.sql @@ -1,7 +1,18 @@ -- +goose Up --- Insert default statuses into the inventory_status table -INSERT INTO inventory_status (name) -VALUES - ('In Stock'), - ('Low Stock'), - ('Out Of Stock'); \ No newline at end of file +-- Create the lookup table for inventory item statuses +CREATE TABLE inventory_status ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Insert common default statuses needed for the next migration (00008) +INSERT INTO inventory_status (name) VALUES +('In Stock'), +('Low Stock'), +('Out of Stock'), +('Expired'), +('Reserved'); +-- Add any other statuses that might exist in the old 'status' text column + +-- +goose Down +DROP TABLE IF EXISTS inventory_status; \ No newline at end of file diff --git a/backend/migrations/00008_modify_inventory_and_harvest_units.sql b/backend/migrations/00008_modify_inventory_and_harvest_units.sql deleted file mode 100644 index e3b32dd..0000000 --- a/backend/migrations/00008_modify_inventory_and_harvest_units.sql +++ /dev/null @@ -1,70 +0,0 @@ --- +goose Up --- Step 1: Create inventory_category table -CREATE TABLE inventory_category ( - id SERIAL PRIMARY KEY, - name TEXT NOT NULL UNIQUE -); - --- Step 2: Insert sample categories -INSERT INTO inventory_category (name) -VALUES - ('Seeds'), - ('Tools'), - ('Chemicals'); - --- Step 3: Add category_id column to inventory_items -ALTER TABLE inventory_items -ADD COLUMN category_id INT; - --- Step 4: Link inventory_items to inventory_category -ALTER TABLE inventory_items -ADD CONSTRAINT fk_inventory_category FOREIGN KEY (category_id) REFERENCES inventory_category(id) ON DELETE SET NULL; - --- Step 5: Remove old columns (type, category, unit) from inventory_items -ALTER TABLE inventory_items -DROP COLUMN type, -DROP COLUMN category, -DROP COLUMN unit; - --- Step 6: Add unit_id column to inventory_items -ALTER TABLE inventory_items -ADD COLUMN unit_id INT; - --- Step 7: Link inventory_items to harvest_units -ALTER TABLE inventory_items -ADD CONSTRAINT fk_inventory_unit FOREIGN KEY (unit_id) REFERENCES harvest_units(id) ON DELETE SET NULL; - --- Step 8: Insert new unit values into harvest_units -INSERT INTO harvest_units (name) -VALUES - ('Tonne'), - ('KG'); - --- +goose Down --- Reverse Step 8: Remove inserted unit values -DELETE FROM harvest_units WHERE name IN ('Tonne', 'KG'); - --- Reverse Step 7: Remove the foreign key constraint -ALTER TABLE inventory_items -DROP CONSTRAINT fk_inventory_unit; - --- Reverse Step 6: Remove unit_id column from inventory_items -ALTER TABLE inventory_items -DROP COLUMN unit_id; - --- Reverse Step 5: Add back type, category, and unit columns -ALTER TABLE inventory_items -ADD COLUMN type TEXT NOT NULL, -ADD COLUMN category TEXT NOT NULL, -ADD COLUMN unit TEXT NOT NULL; - --- Reverse Step 4: Remove foreign key constraint from inventory_items -ALTER TABLE inventory_items -DROP CONSTRAINT fk_inventory_category; - --- Reverse Step 3: Remove category_id column from inventory_items -ALTER TABLE inventory_items -DROP COLUMN category_id; - --- Reverse Step 2: Drop inventory_category table -DROP TABLE inventory_category; diff --git a/backend/migrations/00008_modify_inventory_table.sql b/backend/migrations/00008_modify_inventory_table.sql new file mode 100644 index 0000000..fdeb4c9 --- /dev/null +++ b/backend/migrations/00008_modify_inventory_table.sql @@ -0,0 +1,46 @@ +-- +goose Up +-- Add the status_id column to link to the new inventory_status table +ALTER TABLE inventory_items +ADD COLUMN status_id INT; + +-- Update the new status_id based on the old text status column +-- This relies on the inventory_status table being populated (done in 00007) +UPDATE inventory_items inv +SET status_id = (SELECT id FROM inventory_status stat WHERE stat.name = inv.status) +WHERE EXISTS (SELECT 1 FROM inventory_status stat WHERE stat.name = inv.status); +-- Handle cases where the old status might not be in the new table (optional: set to a default or log) +-- UPDATE inventory_items SET status_id = WHERE status_id IS NULL; + +-- Drop the old text status column +ALTER TABLE inventory_items +DROP COLUMN status; + +-- Add the foreign key constraint +-- Make status_id NOT NULL if every item must have a status +ALTER TABLE inventory_items +ADD CONSTRAINT fk_inventory_items_status FOREIGN KEY (status_id) REFERENCES inventory_status(id) ON DELETE SET NULL; -- Or ON DELETE RESTRICT + +-- Create an index on the new foreign key column +CREATE INDEX idx_inventory_items_status_id ON inventory_items(status_id); + + +-- +goose Down +-- Drop the index +DROP INDEX IF EXISTS idx_inventory_items_status_id; + +-- Drop the foreign key constraint +ALTER TABLE inventory_items +DROP CONSTRAINT IF EXISTS fk_inventory_items_status; + +-- Add the old status column back +ALTER TABLE inventory_items +ADD COLUMN status TEXT; -- Make NOT NULL if it was originally + +-- Attempt to restore the status text from status_id (data loss if status was deleted) +UPDATE inventory_items inv +SET status = (SELECT name FROM inventory_status stat WHERE stat.id = inv.status_id) +WHERE inv.status_id IS NOT NULL; + +-- Drop the status_id column +ALTER TABLE inventory_items +DROP COLUMN status_id; \ No newline at end of file diff --git a/backend/migrations/00009_add_farm_analytic_view.sql b/backend/migrations/00009_add_farm_analytic_view.sql new file mode 100644 index 0000000..09ed99d --- /dev/null +++ b/backend/migrations/00009_add_farm_analytic_view.sql @@ -0,0 +1,119 @@ +-- +goose Up +-- Description: Recreates farm_analytics_view (Version 2) to include aggregated data +-- from analytics_events and new columns from the farms table. + +-- Drop the existing materialized view (from migration 00005) +DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view; + +-- Recreate the materialized view with aggregated data +CREATE MATERIALIZED VIEW public.farm_analytics_view AS +SELECT + f.uuid AS farm_id, + f.name AS farm_name, + f.owner_id, + f.farm_type, -- Added in 00006 + f.total_size, -- Added in 00006 + -- Determine last update time based on farm update or latest event + COALESCE( + (SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid), + f.updated_at + ) AS last_updated, + + -- Weather data aggregation (Example: Average Temp/Humidity, Forecast List) + ( + SELECT jsonb_build_object( + 'temperature_avg', AVG((ae_w.event_data->>'temperature')::float) FILTER (WHERE ae_w.event_data ? 'temperature'), + 'humidity_avg', AVG((ae_w.event_data->>'humidity')::float) FILTER (WHERE ae_w.event_data ? 'humidity'), + 'forecasts', jsonb_agg(ae_w.event_data->'forecast') FILTER (WHERE ae_w.event_data ? 'forecast') + ) + FROM analytics_events ae_w + WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated' -- Ensure event type is correct + -- GROUP BY ae_w.farm_id -- Not needed inside subquery selecting for one farm + ) AS weather_data, + + -- Inventory data aggregation (Example: Item List, Last Update) + ( + SELECT jsonb_build_object( + 'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb), + 'last_updated', MAX(ae_i.created_at) + ) + FROM analytics_events ae_i + WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated' -- Ensure event type is correct + -- GROUP BY ae_i.farm_id + ) AS inventory_data, + + -- Plant health data aggregation (Example: Latest Status, Issues List) + ( + SELECT jsonb_build_object( + 'status', MAX(ae_p.event_data->>'status'), -- MAX works on text, gets latest alphabetically if not timestamped + 'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb) + -- Consider adding 'last_updated': MAX(ae_p.created_at) + ) + FROM analytics_events ae_p + WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated' -- Ensure event type is correct + -- GROUP BY ae_p.farm_id + ) AS plant_health_data, + + -- Financial data aggregation (Example: Sums) + ( + SELECT jsonb_build_object( + 'revenue', SUM((ae_f.event_data->>'revenue')::float) FILTER (WHERE ae_f.event_data ? 'revenue'), + 'expenses', SUM((ae_f.event_data->>'expenses')::float) FILTER (WHERE ae_f.event_data ? 'expenses'), + 'profit', SUM((ae_f.event_data->>'profit')::float) FILTER (WHERE ae_f.event_data ? 'profit') + -- Consider adding 'last_updated': MAX(ae_f.created_at) + ) + FROM analytics_events ae_f + WHERE ae_f.farm_id = f.uuid AND ae_f.event_type = 'financial.updated' -- Ensure event type is correct + -- GROUP BY ae_f.farm_id + ) AS financial_data, + + -- Production data aggregation (Example: Sum Yield, Latest Forecast) + ( + SELECT jsonb_build_object( + 'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'), + 'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast') -- MAX on text + -- Consider adding 'last_updated': MAX(ae_pr.created_at) + ) + FROM analytics_events ae_pr + WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated' -- Ensure event type is correct + -- GROUP BY ae_pr.farm_id + ) AS production_data + +FROM + public.farms f; -- No need for LEFT JOIN and GROUP BY on the main query for this structure + +-- Recreate indexes for faster queries on the new view structure +CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id); +CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id); + +-- The refresh function and triggers from migration 00005 should still work +-- as they target the view by name ('public.farm_analytics_view'). + +-- +goose Down +-- Drop the recreated materialized view +DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view; + +-- Restore the original simple materialized view (from migration 00005) +CREATE MATERIALIZED VIEW public.farm_analytics_view AS +SELECT + f.uuid AS farm_id, + f.name AS farm_name, + f.owner_id, + -- farm_type and total_size did not exist in the view definition from 00005 + f.created_at, + f.updated_at, + COUNT(ae.id) AS total_events, + MAX(ae.created_at) AS last_event_at +FROM + public.farms f +LEFT JOIN + public.analytics_events ae ON f.uuid = ae.farm_id +GROUP BY + f.uuid, f.name, f.owner_id, f.created_at, f.updated_at; + +-- Recreate indexes for the restored view +CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id); +CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id); + +-- The refresh function and triggers from 00005 are assumed to still exist +-- and will target this restored view definition. \ No newline at end of file diff --git a/backend/migrations/00010_update_crop_table_geo.sql b/backend/migrations/00010_update_crop_table_geo.sql new file mode 100644 index 0000000..0b3b32e --- /dev/null +++ b/backend/migrations/00010_update_crop_table_geo.sql @@ -0,0 +1,16 @@ +-- +goose Up +-- Add a column to store geographical features (marker or polygon) for a cropland. +-- Example JSON structure: +-- {"type": "marker", "position": {"lat": 13.84, "lng": 100.48}} +-- or +-- {"type": "polygon", "path": [{"lat": 13.81, "lng": 100.40}, ...]} +ALTER TABLE croplands +ADD COLUMN geo_feature JSONB; + +-- Consider adding a GIN index if querying within the JSON often +-- CREATE INDEX idx_croplands_geo_feature ON croplands USING GIN (geo_feature); + +-- +goose Down +-- Remove the geo_feature column +ALTER TABLE croplands +DROP COLUMN IF EXISTS geo_feature; \ No newline at end of file diff --git a/backend/migrations/00011_update_analytics_view.sql b/backend/migrations/00011_update_analytics_view.sql new file mode 100644 index 0000000..a3c9660 --- /dev/null +++ b/backend/migrations/00011_update_analytics_view.sql @@ -0,0 +1,157 @@ +-- +goose Up +-- Description: Updates the farm_analytics_view (Version 3) to remove financial data +-- and fetch the *latest* weather data instead of aggregating. + +-- Drop the existing view (from migration 00009) +DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view; + +-- Recreate the materialized view with updated logic +CREATE MATERIALIZED VIEW public.farm_analytics_view AS +SELECT + f.uuid AS farm_id, + f.name AS farm_name, + f.owner_id, + f.farm_type, + f.total_size, + -- Determine last update time based on farm update or latest event + COALESCE( + (SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid), + f.updated_at + ) AS last_updated, + + -- Weather data: Select the *latest* 'weather.updated' event data for the farm + ( + SELECT jsonb_build_object( + 'last_updated', latest_weather.created_at, -- Use the event timestamp + 'temperature', (latest_weather.event_data->>'temperature')::float, + 'humidity', (latest_weather.event_data->>'humidity')::float, + 'rainfall', (latest_weather.event_data->>'rainfall')::float, -- Assuming 'rainfall' exists + 'wind_speed', (latest_weather.event_data->>'wind_speed')::float, + 'weather_status', latest_weather.event_data->>'weather_status', -- Assuming 'weather_status' exists + 'alert_level', latest_weather.event_data->>'alert_level', -- Assuming 'alert_level' exists + 'forecast_summary', latest_weather.event_data->>'forecast_summary' -- Assuming 'forecast_summary' exists + -- Add more fields here, ensuring they exist in your 'weather.updated' event_data JSON + ) + FROM ( + -- Find the most recent weather event for this farm + SELECT ae_w.event_data, ae_w.created_at + FROM public.analytics_events ae_w + WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated' -- Make sure event_type is correct + ORDER BY ae_w.created_at DESC + LIMIT 1 + ) AS latest_weather + ) AS weather_data, -- This will be NULL if no 'weather.updated' event exists for the farm + + -- Inventory data aggregation (Keep logic from V2 or refine) + ( + SELECT jsonb_build_object( + 'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb), + 'last_updated', MAX(ae_i.created_at) + ) + FROM analytics_events ae_i + WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated' -- Ensure event type is correct + ) AS inventory_data, + + -- Plant health data aggregation (Keep logic from V2 or refine) + ( + SELECT jsonb_build_object( + 'status', MAX(ae_p.event_data->>'status'), + 'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb), + 'last_updated', MAX(ae_p.created_at) + ) + FROM analytics_events ae_p + WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated' -- Ensure event type is correct + ) AS plant_health_data, + + -- Financial data aggregation -- REMOVED -- + + -- Production data aggregation (Keep logic from V2 or refine) + ( + SELECT jsonb_build_object( + 'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'), + 'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast'), + 'last_updated', MAX(ae_pr.created_at) + ) + FROM analytics_events ae_pr + WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated' -- Ensure event type is correct + ) AS production_data + +FROM + public.farms f; + +-- Recreate indexes for faster queries on the new view structure +CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id); +CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id); + +-- The refresh function and triggers from migration 00005 should still work. + +-- +goose Down +-- Revert to the previous version (from migration 00009) +-- Drop the modified view +DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view; + +-- Recreate the view from migration 00009 (including financial data and aggregated weather) +CREATE MATERIALIZED VIEW public.farm_analytics_view AS +SELECT + f.uuid AS farm_id, + f.name AS farm_name, + f.owner_id, + f.farm_type, + f.total_size, + COALESCE( + (SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid), + f.updated_at + ) AS last_updated, + ( + SELECT jsonb_build_object( + 'temperature_avg', AVG((ae_w.event_data->>'temperature')::float) FILTER (WHERE ae_w.event_data ? 'temperature'), + 'humidity_avg', AVG((ae_w.event_data->>'humidity')::float) FILTER (WHERE ae_w.event_data ? 'humidity'), + 'forecasts', jsonb_agg(ae_w.event_data->'forecast') FILTER (WHERE ae_w.event_data ? 'forecast') + ) + FROM analytics_events ae_w + WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated' + ) AS weather_data, + ( + SELECT jsonb_build_object( + 'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb), + 'last_updated', MAX(ae_i.created_at) + ) + FROM analytics_events ae_i + WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated' + ) AS inventory_data, + ( + SELECT jsonb_build_object( + 'status', MAX(ae_p.event_data->>'status'), + 'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb), + 'last_updated', MAX(ae_p.created_at) + ) + FROM analytics_events ae_p + WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated' + ) AS plant_health_data, + ( + SELECT jsonb_build_object( + 'revenue', SUM((ae_f.event_data->>'revenue')::float) FILTER (WHERE ae_f.event_data ? 'revenue'), + 'expenses', SUM((ae_f.event_data->>'expenses')::float) FILTER (WHERE ae_f.event_data ? 'expenses'), + 'profit', SUM((ae_f.event_data->>'profit')::float) FILTER (WHERE ae_f.event_data ? 'profit'), + 'last_updated', MAX(ae_f.created_at) + ) + FROM analytics_events ae_f + WHERE ae_f.farm_id = f.uuid AND ae_f.event_type = 'financial.updated' + ) AS financial_data, + ( + SELECT jsonb_build_object( + 'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'), + 'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast'), + 'last_updated', MAX(ae_pr.created_at) + ) + FROM analytics_events ae_pr + WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated' + ) AS production_data +FROM + public.farms f; + +-- Recreate indexes for the V2 view structure +CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id); +CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id); + +-- The refresh function and triggers from 00005 are assumed to still exist. \ No newline at end of file diff --git a/backend/migrations/00012_create_crop_analytics_view.sql b/backend/migrations/00012_create_crop_analytics_view.sql new file mode 100644 index 0000000..983aea1 --- /dev/null +++ b/backend/migrations/00012_create_crop_analytics_view.sql @@ -0,0 +1,64 @@ +-- +goose Up +-- Description: Creates a materialized view for crop-level analytics, +-- pulling data directly from croplands and plants tables. + +CREATE MATERIALIZED VIEW public.crop_analytics_view AS +SELECT + c.uuid AS crop_id, + c.name AS crop_name, + c.farm_id, + p.name AS plant_name, + p.variety AS variety, -- Include variety from plants table + c.status AS current_status, + c.growth_stage, + c.land_size, + c.geo_feature, -- Include geo_feature added in 00010 + c.updated_at AS last_updated -- Use cropland's updated_at as the primary refresh indicator + -- Add columns here if CropAnalytics struct includes more fields derived directly + -- from croplands or plants tables. Event-derived data would need different handling. +FROM + public.croplands c +JOIN + public.plants p ON c.plant_id = p.uuid; + +-- Create indexes for efficient querying +CREATE UNIQUE INDEX idx_crop_analytics_view_crop_id ON public.crop_analytics_view(crop_id); +CREATE INDEX idx_crop_analytics_view_farm_id ON public.crop_analytics_view(farm_id); +CREATE INDEX idx_crop_analytics_view_plant_name ON public.crop_analytics_view(plant_name); -- Added index + +-- Create a dedicated function to refresh this new view +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION public.refresh_crop_analytics_view() +RETURNS TRIGGER AS $$ +BEGIN + -- Use CONCURRENTLY to avoid locking the view during refresh + REFRESH MATERIALIZED VIEW CONCURRENTLY public.crop_analytics_view; + RETURN NULL; -- result is ignored since this is an AFTER trigger +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +-- Create triggers to refresh the view when underlying data changes +-- Trigger on Croplands table changes +CREATE TRIGGER refresh_crop_analytics_trigger_croplands +AFTER INSERT OR UPDATE OR DELETE ON public.croplands +FOR EACH STATEMENT -- Refresh once per statement that modifies the table +EXECUTE FUNCTION public.refresh_crop_analytics_view(); + +-- Trigger on Plants table changes (e.g., if plant name/variety is updated) +CREATE TRIGGER refresh_crop_analytics_trigger_plants +AFTER INSERT OR UPDATE OR DELETE ON public.plants +FOR EACH STATEMENT +EXECUTE FUNCTION public.refresh_crop_analytics_view(); + + +-- +goose Down +-- Drop triggers first +DROP TRIGGER IF EXISTS refresh_crop_analytics_trigger_croplands ON public.croplands; +DROP TRIGGER IF EXISTS refresh_crop_analytics_trigger_plants ON public.plants; + +-- Drop the refresh function +DROP FUNCTION IF EXISTS public.refresh_crop_analytics_view(); + +-- Drop the materialized view and its indexes +DROP MATERIALIZED VIEW IF EXISTS public.crop_analytics_view; -- Indexes are dropped automatically \ No newline at end of file diff --git a/backend/migrations/00013_modify_inventory_and_harvest_units.sql b/backend/migrations/00013_modify_inventory_and_harvest_units.sql new file mode 100644 index 0000000..7f68451 --- /dev/null +++ b/backend/migrations/00013_modify_inventory_and_harvest_units.sql @@ -0,0 +1,120 @@ +-- +goose Up +-- Step 1: Create inventory_category table +CREATE TABLE inventory_category ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); + +-- Step 2: Insert sample categories (add more as needed) +INSERT INTO inventory_category (name) +VALUES + ('Seeds'), + ('Fertilizers'), + ('Pesticides'), + ('Herbicides'), + ('Tools'), + ('Equipment'), + ('Fuel'), + ('Harvested Goods'), -- If harvested items go into inventory + ('Other'); + +-- Step 3: Add category_id column to inventory_items +ALTER TABLE inventory_items +ADD COLUMN category_id INT; + +-- Step 4: Update category_id based on old 'category' text (best effort) +-- Map known old categories to new IDs. Set others to 'Other' or NULL. +UPDATE inventory_items inv SET category_id = (SELECT id FROM inventory_category cat WHERE cat.name = inv.category) +WHERE EXISTS (SELECT 1 FROM inventory_category cat WHERE cat.name = inv.category); +-- Example: Set remaining to 'Other' +-- UPDATE inventory_items SET category_id = (SELECT id FROM inventory_category WHERE name = 'Other') WHERE category_id IS NULL; + +-- Step 5: Add foreign key constraint for category_id +ALTER TABLE inventory_items +ADD CONSTRAINT fk_inventory_category FOREIGN KEY (category_id) REFERENCES inventory_category(id) ON DELETE SET NULL; -- Or RESTRICT + +-- Step 6: Add unit_id column to inventory_items (linking to harvest_units) +ALTER TABLE inventory_items +ADD COLUMN unit_id INT; + +-- Step 7: Insert common inventory units into harvest_units table (if they don't exist) +-- harvest_units was created in 00002, potentially with crop-specific units. Add general ones here. +INSERT INTO harvest_units (name) VALUES + ('Piece(s)'), + ('Bag(s)'), + ('Box(es)'), + ('Liter(s)'), + ('Gallon(s)'), + ('kg'), -- Use consistent casing, e.g., lowercase + ('tonne'), + ('meter(s)'), + ('hour(s)') +ON CONFLICT (name) DO NOTHING; -- Avoid errors if units already exist + +-- Step 8: Update unit_id based on old 'unit' text (best effort) +UPDATE inventory_items inv SET unit_id = (SELECT id FROM harvest_units hu WHERE hu.name = inv.unit) +WHERE EXISTS (SELECT 1 FROM harvest_units hu WHERE hu.name = inv.unit); +-- Handle cases where the old unit might not be in the harvest_units table (optional) +-- UPDATE inventory_items SET unit_id = WHERE unit_id IS NULL; + +-- Step 9: Add foreign key constraint for unit_id +ALTER TABLE inventory_items +ADD CONSTRAINT fk_inventory_unit FOREIGN KEY (unit_id) REFERENCES harvest_units(id) ON DELETE SET NULL; -- Or RESTRICT + +-- Step 10: Remove old columns (type, category, unit) from inventory_items +ALTER TABLE inventory_items +DROP COLUMN type, +DROP COLUMN category, +DROP COLUMN unit; + +-- Step 11: Add indexes for new foreign keys +CREATE INDEX idx_inventory_items_category_id ON inventory_items(category_id); +CREATE INDEX idx_inventory_items_unit_id ON inventory_items(unit_id); + + +-- +goose Down +-- Reverse Step 11: Drop indexes +DROP INDEX IF EXISTS idx_inventory_items_category_id; +DROP INDEX IF EXISTS idx_inventory_items_unit_id; + +-- Reverse Step 10: Add back type, category, and unit columns +-- Mark as NOT NULL if they were originally required +ALTER TABLE inventory_items +ADD COLUMN type TEXT, +ADD COLUMN category TEXT, +ADD COLUMN unit TEXT; + +-- Attempt to restore data (best effort, potential data loss) +UPDATE inventory_items inv SET category = (SELECT name FROM inventory_category cat WHERE cat.id = inv.category_id) +WHERE inv.category_id IS NOT NULL; +UPDATE inventory_items inv SET unit = (SELECT name FROM harvest_units hu WHERE hu.id = inv.unit_id) +WHERE inv.unit_id IS NOT NULL; +-- Cannot restore 'type' as it was dropped without replacement. + +-- Reverse Step 9: Remove the foreign key constraint for unit +ALTER TABLE inventory_items +DROP CONSTRAINT IF EXISTS fk_inventory_unit; + +-- Reverse Step 8: (Data restoration attempted above) + +-- Reverse Step 7: (Cannot easily remove only units added here without knowing originals) +-- DELETE FROM harvest_units WHERE name IN ('Piece(s)', 'Bag(s)', ...); -- Risky if names overlapped + +-- Reverse Step 6: Remove unit_id column from inventory_items +ALTER TABLE inventory_items +DROP COLUMN unit_id; + +-- Reverse Step 5: Remove foreign key constraint for category +ALTER TABLE inventory_items +DROP CONSTRAINT IF EXISTS fk_inventory_category; + +-- Reverse Step 4: (Data restoration attempted above) + +-- Reverse Step 3: Remove category_id column from inventory_items +ALTER TABLE inventory_items +DROP COLUMN category_id; + +-- Reverse Step 2: (Data in inventory_category table is kept unless explicitly dropped) + +-- Reverse Step 1: Drop inventory_category table +DROP TABLE IF EXISTS inventory_category; \ No newline at end of file diff --git a/backend/migrations/00014_create_farm_analytics_table.sql b/backend/migrations/00014_create_farm_analytics_table.sql new file mode 100644 index 0000000..dfdc465 --- /dev/null +++ b/backend/migrations/00014_create_farm_analytics_table.sql @@ -0,0 +1,159 @@ +-- +goose Up + +DROP MATERIALIZED VIEW IF EXISTS public.farm_analytics_view CASCADE; +DROP FUNCTION IF EXISTS public.refresh_farm_analytics_view() CASCADE; +DROP MATERIALIZED VIEW IF EXISTS public.crop_analytics_view CASCADE; +DROP FUNCTION IF EXISTS public.refresh_crop_analytics_view() CASCADE; + +CREATE TABLE public.farm_analytics ( + farm_id UUID PRIMARY KEY NOT NULL, + farm_name TEXT NOT NULL, + owner_id UUID NOT NULL, + farm_type TEXT, + total_size TEXT, + latitude DOUBLE PRECISION NOT NULL, + longitude DOUBLE PRECISION NOT NULL, + + weather_temp_celsius DOUBLE PRECISION, + weather_humidity DOUBLE PRECISION, + weather_description TEXT, + weather_icon TEXT, + weather_wind_speed DOUBLE PRECISION, + weather_rain_1h DOUBLE PRECISION, + weather_observed_at TIMESTAMPTZ, -- Timestamp from the weather data itself + weather_last_updated TIMESTAMPTZ, -- Timestamp when weather was last fetched/updated in this record + + inventory_total_items INT DEFAULT 0 NOT NULL, + inventory_low_stock_count INT DEFAULT 0 NOT NULL, + inventory_last_updated TIMESTAMPTZ, + + crop_total_count INT DEFAULT 0 NOT NULL, + crop_growing_count INT DEFAULT 0 NOT NULL, -- Example: specific status count + crop_last_updated TIMESTAMPTZ, + + overall_status TEXT, -- e.g., 'ok', 'warning', 'critical' - Can be updated by various events + + analytics_last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- When this specific analytics record was last touched + + CONSTRAINT fk_farm_analytics_farm FOREIGN KEY (farm_id) REFERENCES public.farms(uuid) ON DELETE CASCADE, + CONSTRAINT fk_farm_analytics_owner FOREIGN KEY (owner_id) REFERENCES public.users(uuid) ON DELETE CASCADE -- Assuming owner_id refers to users.uuid +); + +CREATE INDEX idx_farm_analytics_owner_id ON public.farm_analytics(owner_id); +CREATE INDEX idx_farm_analytics_last_updated ON public.farm_analytics(analytics_last_updated DESC); +CREATE INDEX idx_farm_analytics_weather_last_updated ON public.farm_analytics(weather_last_updated DESC); + +-- Optional: Initial data population (run once after table creation if needed) +-- INSERT INTO public.farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, analytics_last_updated) +-- SELECT uuid, name, owner_id, farm_type, total_size, lat, lon, updated_at +-- FROM public.farms +-- ON CONFLICT (farm_id) DO NOTHING; + + +-- +goose Down + +DROP TABLE IF EXISTS public.farm_analytics; + +CREATE MATERIALIZED VIEW public.farm_analytics_view AS +SELECT + f.uuid AS farm_id, + f.name AS farm_name, + f.owner_id, + f.farm_type, + f.total_size, + COALESCE( + (SELECT MAX(ae_max.created_at) FROM public.analytics_events ae_max WHERE ae_max.farm_id = f.uuid), + f.updated_at + ) AS last_updated, + ( + SELECT jsonb_build_object( + 'last_updated', latest_weather.created_at, + 'temperature', (latest_weather.event_data->>'temperature')::float, + 'humidity', (latest_weather.event_data->>'humidity')::float, + 'rainfall', (latest_weather.event_data->>'rainfall')::float, + 'wind_speed', (latest_weather.event_data->>'wind_speed')::float, + 'weather_status', latest_weather.event_data->>'weather_status', + 'alert_level', latest_weather.event_data->>'alert_level', + 'forecast_summary', latest_weather.event_data->>'forecast_summary' + ) + FROM ( + SELECT ae_w.event_data, ae_w.created_at + FROM public.analytics_events ae_w + WHERE ae_w.farm_id = f.uuid AND ae_w.event_type = 'weather.updated' + ORDER BY ae_w.created_at DESC + LIMIT 1 + ) AS latest_weather + ) AS weather_data, + ( + SELECT jsonb_build_object( + 'items', COALESCE(jsonb_agg(ae_i.event_data->'items' ORDER BY (ae_i.event_data->>'timestamp') DESC) FILTER (WHERE ae_i.event_data ? 'items'), '[]'::jsonb), + 'last_updated', MAX(ae_i.created_at) + ) + FROM analytics_events ae_i + WHERE ae_i.farm_id = f.uuid AND ae_i.event_type = 'inventory.updated' + ) AS inventory_data, + ( + SELECT jsonb_build_object( + 'status', MAX(ae_p.event_data->>'status'), + 'issues', COALESCE(jsonb_agg(ae_p.event_data->'issues') FILTER (WHERE ae_p.event_data ? 'issues'), '[]'::jsonb), + 'last_updated', MAX(ae_p.created_at) + ) + FROM analytics_events ae_p + WHERE ae_p.farm_id = f.uuid AND ae_p.event_type = 'plant_health.updated' + ) AS plant_health_data, + ( + SELECT jsonb_build_object( + 'yield_total', SUM((ae_pr.event_data->>'yield')::float) FILTER (WHERE ae_pr.event_data ? 'yield'), + 'forecast_latest', MAX(ae_pr.event_data->>'forecast') FILTER (WHERE ae_pr.event_data ? 'forecast'), + 'last_updated', MAX(ae_pr.created_at) + ) + FROM analytics_events ae_pr + WHERE ae_pr.farm_id = f.uuid AND ae_pr.event_type = 'production.updated' + ) AS production_data +FROM + public.farms f; + +CREATE UNIQUE INDEX idx_farm_analytics_view_farm_id ON public.farm_analytics_view(farm_id); +CREATE INDEX idx_farm_analytics_view_owner_id ON public.farm_analytics_view(owner_id); + +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION public.refresh_farm_analytics_view() +RETURNS TRIGGER AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY public.farm_analytics_view; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd + +CREATE TRIGGER refresh_farm_analytics_view_trigger_events +AFTER INSERT ON public.analytics_events -- Adjust if original trigger was different +FOR EACH STATEMENT +EXECUTE FUNCTION public.refresh_farm_analytics_view(); + +CREATE TRIGGER refresh_farm_analytics_view_trigger_farms +AFTER INSERT OR UPDATE OR DELETE ON public.farms -- Adjust if original trigger was different +FOR EACH STATEMENT +EXECUTE FUNCTION public.refresh_farm_analytics_view(); + +CREATE MATERIALIZED VIEW public.crop_analytics_view AS +SELECT + c.uuid AS crop_id, c.name AS crop_name, c.farm_id, p.name AS plant_name, p.variety AS variety, + c.status AS current_status, c.growth_stage, c.land_size, c.geo_feature, c.updated_at AS last_updated +FROM public.croplands c JOIN public.plants p ON c.plant_id = p.uuid; +CREATE UNIQUE INDEX idx_crop_analytics_view_crop_id ON public.crop_analytics_view(crop_id); +CREATE INDEX idx_crop_analytics_view_farm_id ON public.crop_analytics_view(farm_id); +CREATE INDEX idx_crop_analytics_view_plant_name ON public.crop_analytics_view(plant_name); +-- +goose StatementBegin +CREATE OR REPLACE FUNCTION public.refresh_crop_analytics_view() +RETURNS TRIGGER AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY public.crop_analytics_view; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +-- +goose StatementEnd +CREATE TRIGGER refresh_crop_analytics_trigger_croplands +AFTER INSERT OR UPDATE OR DELETE ON public.croplands FOR EACH STATEMENT EXECUTE FUNCTION public.refresh_crop_analytics_view(); +CREATE TRIGGER refresh_crop_analytics_trigger_plants +AFTER INSERT OR UPDATE OR DELETE ON public.plants FOR EACH STATEMENT EXECUTE FUNCTION public.refresh_crop_analytics_view(); \ No newline at end of file diff --git a/backend/migrations/embed.go b/backend/migrations/embed.go index 3e96d80..70280a3 100644 --- a/backend/migrations/embed.go +++ b/backend/migrations/embed.go @@ -6,3 +6,5 @@ import ( //go:embed *.sql var EmbedMigrations embed.FS + +const MigrationsDir = "migrations" diff --git a/frontend/api/crop.ts b/frontend/api/crop.ts new file mode 100644 index 0000000..a8eafe8 --- /dev/null +++ b/frontend/api/crop.ts @@ -0,0 +1,54 @@ +import axiosInstance from "./config"; +// Use refactored types +import type { Cropland, CropAnalytics } from "@/types"; + +export interface CropResponse { + croplands: Cropland[]; +} + +/** + * Fetch all Croplands for a specific FarmID. Returns CropResponse. + */ +export async function getCropsByFarmId(farmId: string): Promise { + // Assuming backend returns { "croplands": [...] } + return axiosInstance.get(`/crop/farm/${farmId}`).then((res) => res.data); +} + +/** + * Fetch a specific Cropland by its ID. Returns Cropland. + */ +export async function getCropById(cropId: string): Promise { + // Assuming backend returns { "cropland": ... } + return axiosInstance.get(`/crop/${cropId}`).then((res) => res.data); + // If backend returns object directly: return axiosInstance.get(`/crop/${cropId}`).then((res) => res.data); +} + +/** + * Create a new crop (Cropland). Sends camelCase data matching backend tags. Returns Cropland. + */ +export async function createCrop(data: Partial>): Promise { + if (!data.farmId) { + throw new Error("farmId is required to create a crop."); + } + // Payload uses camelCase keys matching backend JSON tags + const payload = { + name: data.name, + status: data.status, + priority: data.priority, + landSize: data.landSize, + growthStage: data.growthStage, + plantId: data.plantId, + farmId: data.farmId, + geoFeature: data.geoFeature, // Send the GeoFeature object + }; + return axiosInstance.post<{ cropland: Cropland }>(`/crop`, payload).then((res) => res.data.cropland); // Assuming backend wraps in { "cropland": ... } + // If backend returns object directly: return axiosInstance.post(`/crop`, payload).then((res) => res.data); +} + +/** + * Fetch analytics data for a specific crop by its ID. Returns CropAnalytics. + */ +export async function fetchCropAnalytics(cropId: string): Promise { + // Assuming backend returns { body: { ... } } structure from Huma + return axiosInstance.get(`/analytics/crop/${cropId}`).then((res) => res.data); +} diff --git a/frontend/api/farm.ts b/frontend/api/farm.ts index 4e18614..0071ddb 100644 --- a/frontend/api/farm.ts +++ b/frontend/api/farm.ts @@ -1,198 +1,63 @@ import axiosInstance from "./config"; -import type { Crop, CropAnalytics, Farm } from "@/types"; +// Use the refactored Farm type +import type { Farm } from "@/types"; /** - * Fetch a specific crop by id using axios. - * Falls back to dummy data on error. - */ -export async function fetchCropById(id: string): Promise { - try { - const response = await axiosInstance.get(`/api/crops/${id}`); - return response.data; - } catch (error) { - // Fallback dummy data - return { - id, - farmId: "1", - name: "Monthong Durian", - plantedDate: new Date("2024-01-15"), - status: "growing", - variety: "Premium Grade", - expectedHarvest: new Date("2024-07-15"), - area: "2.5 hectares", - healthScore: 85, - }; - } -} - -/** - * Fetch crop analytics by crop id using axios. - * Returns dummy analytics if the API call fails. - */ -export async function fetchAnalyticsByCropId(id: string): Promise { - try { - const response = await axiosInstance.get(`/api/crops/${id}/analytics`); - return response.data; - } catch (error) { - return { - cropId: id, - growthProgress: 45, - humidity: 75, - temperature: 28, - sunlight: 85, - waterLevel: 65, - plantHealth: "good", - nextAction: "Water the plant", - nextActionDue: new Date("2024-02-15"), - soilMoisture: 70, - windSpeed: "12 km/h", - rainfall: "25mm last week", - nutrientLevels: { - nitrogen: 80, - phosphorus: 65, - potassium: 75, - }, - }; - } -} - -/** - * Fetch an array of farms using axios. - * Simulates a delay and a random error; returns dummy data if the API is unavailable. + * Fetch an array of farms. Returns Farm[]. */ export async function fetchFarms(): Promise { - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 1000)); - - try { - const response = await axiosInstance.get("/api/farms"); - return response.data; - } catch (error) { - // Optionally, you could simulate a random error here. For now we return fallback data. - return [ - { - id: "1", - name: "Green Valley Farm", - location: "Bangkok", - type: "durian", - createdAt: new Date("2023-01-01"), - area: "12.5 hectares", - crops: 5, - }, - { - id: "2", - name: "Sunrise Orchard", - location: "Chiang Mai", - type: "mango", - createdAt: new Date("2023-02-15"), - area: "8.3 hectares", - crops: 3, - }, - { - id: "3", - name: "Golden Harvest Fields", - location: "Phuket", - type: "rice", - createdAt: new Date("2023-03-22"), - area: "20.1 hectares", - crops: 2, - }, - ]; - } + // Backend already returns camelCase due to updated JSON tags + return axiosInstance.get("/farms").then((res) => res.data); // Assuming backend wraps in { "farms": [...] } + // If backend returns array directly: return axiosInstance.get("/farms").then((res) => res.data); } /** - * Simulates creating a new farm. - * Waits for 800ms and then uses dummy data. + * Create a new farm. Sends camelCase data. Returns Farm. */ -export async function createFarm(data: Partial): Promise { - await new Promise((resolve) => setTimeout(resolve, 800)); - // In a real implementation you might call: - // const response = await axiosInstance.post("/api/farms", data); - // return response.data; - return { - id: Math.random().toString(36).substr(2, 9), - name: data.name!, - location: data.location!, - type: data.type!, - createdAt: new Date(), - area: data.area || "0 hectares", - crops: 0, +export async function createFarm( + data: Partial> +): Promise { + // Construct payload matching backend expected camelCase tags + const payload = { + name: data.name, + lat: data.lat, + lon: data.lon, + farmType: data.farmType, + totalSize: data.totalSize, + // ownerId is added by backend based on token }; + return axiosInstance.post("/farms", payload).then((res) => res.data); } -// Additional functions for fetching crop details remain unchanged... - /** - * Fetch detailed information for a specific farm (including its crops) using axios. - * If the API call fails, returns fallback dummy data. + * Fetch a specific farm by ID. Returns Farm. */ -export async function fetchFarmDetails(farmId: string): Promise<{ farm: Farm; crops: Crop[] }> { - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 1200)); - - try { - const response = await axiosInstance.get<{ farm: Farm; crops: Crop[] }>(`/api/farms/${farmId}`); - return response.data; - } catch (error) { - // If the given farmId is "999", simulate a not found error. - if (farmId === "999") { - throw new Error("FARM_NOT_FOUND"); - } - - const farm: Farm = { - id: farmId, - name: "Green Valley Farm", - location: "Bangkok, Thailand", - type: "durian", - createdAt: new Date("2023-01-15"), - area: "12.5 hectares", - crops: 3, - // Additional details such as weather can be included if needed. - weather: { - temperature: 28, - humidity: 75, - rainfall: "25mm last week", - sunlight: 85, - }, - }; - - const crops: Crop[] = [ - { - id: "1", - farmId, - name: "Monthong Durian", - plantedDate: new Date("2023-03-15"), - status: "growing", - variety: "Premium", - area: "4.2 hectares", - healthScore: 92, - progress: 65, - }, - { - id: "2", - farmId, - name: "Chanee Durian", - plantedDate: new Date("2023-02-20"), - status: "planned", - variety: "Standard", - area: "3.8 hectares", - healthScore: 0, - progress: 0, - }, - { - id: "3", - farmId, - name: "Kradum Durian", - plantedDate: new Date("2022-11-05"), - status: "harvested", - variety: "Premium", - area: "4.5 hectares", - healthScore: 100, - progress: 100, - }, - ]; - - return { farm, crops }; - } +export async function getFarm(farmId: string): Promise { + return axiosInstance.get(`/farms/${farmId}`).then((res) => res.data); // Assuming backend wraps in { "farm": ... } + // If backend returns object directly: return axiosInstance.get(`/farms/${farmId}`).then((res) => res.data); +} + +/** + * Update an existing farm. Sends camelCase data. Returns Farm. + */ +export async function updateFarm( + farmId: string, + data: Partial> +): Promise { + // Construct payload matching backend expected camelCase tags + const payload = { + name: data.name, + lat: data.lat, + lon: data.lon, + farmType: data.farmType, + totalSize: data.totalSize, + }; + return axiosInstance.put(`/farms/${farmId}`, payload).then((res) => res.data); +} + +/** + * Delete a specific farm. Returns { message: string }. + */ +export async function deleteFarm(farmId: string): Promise<{ message: string }> { + return axiosInstance.delete(`/farms/${farmId}`).then((res) => res.data); } diff --git a/frontend/api/harvest.ts b/frontend/api/harvest.ts new file mode 100644 index 0000000..4961212 --- /dev/null +++ b/frontend/api/harvest.ts @@ -0,0 +1,12 @@ +import axiosInstance from "./config"; +import type { HarvestUnits } from "@/types"; + +export async function fetchHarvestUnits(): Promise { + try { + const response = await axiosInstance.get("/harvest/units"); + return response.data; + } catch (error) { + console.error("Error fetching inventory status:", error); + return []; + } +} diff --git a/frontend/api/inventory.ts b/frontend/api/inventory.ts index 1c59e20..6b6b5c0 100644 --- a/frontend/api/inventory.ts +++ b/frontend/api/inventory.ts @@ -1,20 +1,23 @@ import axiosInstance from "./config"; import type { InventoryItem, + InventoryStatus, + InventoryItemCategory, CreateInventoryItemInput, - InventoryItemStatus, + EditInventoryItemInput, } from "@/types"; +import { AxiosError } from "axios"; /** * Simulates an API call to fetch inventory items. * Waits for a simulated delay and then attempts an axios GET request. * If the request fails, returns fallback dummy data. - * - * + * + * */ -export async function fetchInventoryStatus(): Promise { +export async function fetchInventoryStatus(): Promise { try { - const response = await axiosInstance.get( + const response = await axiosInstance.get( "/inventory/status" ); return response.data; @@ -23,96 +26,136 @@ export async function fetchInventoryStatus(): Promise { return []; } } +export async function fetchInventoryCategory(): Promise< + InventoryItemCategory[] +> { + try { + const response = await axiosInstance.get( + "/inventory/category" + ); + return response.data; + } catch (error) { + console.error("Error fetching inventory status:", error); + return []; + } +} export async function fetchInventoryItems(): Promise { try { - const response = await axiosInstance.get("/api/inventory"); + const response = await axiosInstance.get("/inventory"); return response.data; } catch (error) { - // Fallback dummy data - return [ - { - id: 1, - name: "Tomato Seeds", - category: "Seeds", - type: "Plantation", - quantity: 500, - unit: "packets", - lastUpdated: "2023-03-01", - status: "In Stock", - }, - { - id: 2, - name: "NPK Fertilizer", - category: "Fertilizer", - type: "Fertilizer", - quantity: 200, - unit: "kg", - lastUpdated: "2023-03-05", - status: "Low Stock", - }, - { - id: 3, - name: "Corn Seeds", - category: "Seeds", - type: "Plantation", - quantity: 300, - unit: "packets", - lastUpdated: "2023-03-10", - status: "In Stock", - }, - { - id: 4, - name: "Organic Compost", - category: "Fertilizer", - type: "Fertilizer", - quantity: 150, - unit: "kg", - lastUpdated: "2023-03-15", - status: "Out Of Stock", - }, - { - id: 5, - name: "Wheat Seeds", - category: "Seeds", - type: "Plantation", - quantity: 250, - unit: "packets", - lastUpdated: "2023-03-20", - status: "In Stock", - }, - ]; + console.error("Error while fetching inventory items! " + error); + throw error; } } -/** - * Simulates creating a new inventory item. - * Uses axios POST and if unavailable, returns a simulated response. - * - * Note: The function accepts all fields except id, lastUpdated, and status. - */ export async function createInventoryItem( - item: Omit + item: Omit ): Promise { - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 500)); try { const response = await axiosInstance.post( - "/api/inventory", + "/inventory", item ); return response.data; - } catch (error) { - // Simulate successful creation if API endpoint is not available - return { - id: Math.floor(Math.random() * 1000), - name: item.name, - category: item.category, - type: item.type, - quantity: item.quantity, - unit: item.unit, - lastUpdated: new Date().toISOString(), - status: "In Stock", - }; + } catch (error: unknown) { + // Cast error to AxiosError to safely access response properties + if (error instanceof AxiosError && error.response) { + // Log the detailed error message + console.error("Error while creating Inventory Item!"); + console.error("Response Status:", error.response.status); // e.g., 422 + console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend + console.error("Full Error Response:", error.response.data); // Entire error object (including details) + + // Throw a new error with a more specific message + throw new Error( + `Failed to create inventory item: ${ + error.response.data?.detail || error.message + }` + ); + } else { + // Handle other errors (e.g., network errors or unknown errors) + console.error( + "Error while creating Inventory Item, unknown error:", + error + ); + throw new Error( + "Failed to create inventory item: " + + (error instanceof Error ? error.message : "Unknown error") + ); + } + } +} + +export async function deleteInventoryItem(id: string) { + try { + const response = await axiosInstance.delete("/inventory/" + id); + return response.data; + } catch (error: unknown) { + // Cast error to AxiosError to safely access response properties + if (error instanceof AxiosError && error.response) { + // Log the detailed error message + console.error("Error while deleting Inventory Item!"); + console.error("Response Status:", error.response.status); // e.g., 422 + console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend + console.error("Full Error Response:", error.response.data); // Entire error object (including details) + + // Throw a new error with a more specific message + throw new Error( + `Failed to delete inventory item: ${ + error.response.data?.detail || error.message + }` + ); + } else { + // Handle other errors (e.g., network errors or unknown errors) + console.error( + "Error while deleting Inventory Item, unknown error:", + error + ); + throw new Error( + "Failed to delete inventory item: " + + (error instanceof Error ? error.message : "Unknown error") + ); + } + } +} +export async function updateInventoryItem( + id: string, + item: EditInventoryItemInput +) { + // console.log(id); + try { + const response = await axiosInstance.put( + `/inventory/${id}`, + item + ); + return response.data; + } catch (error: unknown) { + // Cast error to AxiosError to safely access response properties + if (error instanceof AxiosError && error.response) { + // Log the detailed error message + console.error("Error while deleting Inventory Item!"); + console.error("Response Status:", error.response.status); // e.g., 422 + console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend + console.error("Full Error Response:", error.response.data); // Entire error object (including details) + + // Throw a new error with a more specific message + throw new Error( + `Failed to delete inventory item: ${ + error.response.data?.detail || error.message + }` + ); + } else { + // Handle other errors (e.g., network errors or unknown errors) + console.error( + "Error while deleting Inventory Item, unknown error:", + error + ); + throw new Error( + "Failed to delete inventory item: " + + (error instanceof Error ? error.message : "Unknown error") + ); + } } } diff --git a/frontend/api/plant.ts b/frontend/api/plant.ts new file mode 100644 index 0000000..8b2d6a1 --- /dev/null +++ b/frontend/api/plant.ts @@ -0,0 +1,10 @@ +import axiosInstance from "./config"; +import type { Plant } from "@/types"; + +export interface PlantResponse { + plants: Plant[]; +} + +export function getPlants(): Promise { + return axiosInstance.get("/plant").then((res) => res.data); +} diff --git a/frontend/app/(sidebar)/farms/[farmId]/add-crop-form.tsx b/frontend/app/(sidebar)/farms/[farmId]/add-crop-form.tsx index ce33e21..c18b374 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/add-crop-form.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/add-crop-form.tsx @@ -7,62 +7,131 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { Crop } from "@/types"; -import { cropFormSchema } from "@/schemas/form.schema"; +import { useQuery } from "@tanstack/react-query"; +import { getPlants, PlantResponse } from "@/api/plant"; +import { Loader2 } from "lucide-react"; +import { Cropland } from "@/types"; + +// Update schema to reflect Cropland fields needed for creation +// Removed plantedDate as it's derived from createdAt on backend +// Added plantId, landSize, growthStage, priority +const cropFormSchema = z.object({ + name: z.string().min(2, "Crop name must be at least 2 characters"), + plantId: z.string().uuid("Please select a valid plant"), // Changed from name to ID + status: z.enum(["planned", "growing", "harvested", "fallow"]), // Added fallow + landSize: z.preprocess( + (val) => parseFloat(z.string().parse(val)), // Convert string input to number + z.number().positive("Land size must be a positive number") + ), + growthStage: z.string().min(1, "Growth stage is required"), + priority: z.preprocess( + (val) => parseInt(z.string().parse(val), 10), // Convert string input to number + z.number().int().min(0, "Priority must be non-negative") + ), + // GeoFeature will be handled separately by the map component later +}); interface AddCropFormProps { - onSubmit: (data: Partial) => Promise; + onSubmit: (data: Partial) => Promise; // Expect Partial onCancel: () => void; + isSubmitting: boolean; // Receive submitting state } -export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) { +export function AddCropForm({ onSubmit, onCancel, isSubmitting }: AddCropFormProps) { + // Fetch plants for the dropdown + const { + data: plantData, + isLoading: isLoadingPlants, + isError: isErrorPlants, + } = useQuery({ + queryKey: ["plants"], + queryFn: getPlants, + staleTime: 1000 * 60 * 60, // Cache for 1 hour + }); + const form = useForm>({ resolver: zodResolver(cropFormSchema), defaultValues: { name: "", - plantedDate: "", + plantId: "", // Initialize plantId status: "planned", + landSize: 0, + growthStage: "Planned", + priority: 1, }, }); const handleSubmit = (values: z.infer) => { + // Submit data shaped like Partial onSubmit({ - ...values, - plantedDate: new Date(values.plantedDate), + name: values.name, + plantId: values.plantId, + status: values.status, + landSize: values.landSize, + growthStage: values.growthStage, + priority: values.priority, + // farmId is added in the parent component's mutationFn + // geoFeature would be passed separately if using map here }); }; return (
- + ( - Crop Name + Cropland Name - + )} /> + {/* Plant Selection */} ( - Planted Date - - - + Select Plant + )} /> + {/* Status Selection */} Planned Growing Harvested + Fallow @@ -86,11 +156,73 @@ export function AddCropForm({ onSubmit, onCancel }: AddCropFormProps) { )} /> -
- - +
diff --git a/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx b/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx index e4e0d35..4fb6d95 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-card.tsx @@ -1,19 +1,30 @@ "use client"; import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"; -import { Sprout, Calendar, ArrowRight, BarChart } from "lucide-react"; +import { Calendar, ArrowRight, Layers } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; -import type { Crop } from "@/types"; +import type { Cropland } from "@/types"; +// =================================================================== +// Component Props: CropCard expects a cropland object and an optional click handler. +// =================================================================== interface CropCardProps { - crop: Crop; + crop: Cropland; // Crop data conforming to the Cropland type onClick?: () => void; } +// =================================================================== +// Component: CropCard +// - Displays summary information about a crop, including status, +// created date, and growth stage using an expressive card UI. +// =================================================================== export function CropCard({ crop, onClick }: CropCardProps) { - const statusColors = { + // --------------------------------------------------------------- + // Status color mapping: Determines badge styling based on crop status. + // Default colors provided for unknown statuses. + // --------------------------------------------------------------- + const statusColors: Record = { growing: { bg: "bg-green-50 dark:bg-green-900", text: "text-green-600 dark:text-green-300", @@ -29,67 +40,73 @@ export function CropCard({ crop, onClick }: CropCardProps) { text: "text-blue-600 dark:text-blue-300", border: "border-blue-200", }, + fallow: { + bg: "bg-gray-50 dark:bg-gray-900", + text: "text-gray-600 dark:text-gray-400", + border: "border-gray-200", + }, + default: { + bg: "bg-gray-100 dark:bg-gray-700", + text: "text-gray-800 dark:text-gray-200", + border: "border-gray-300", + }, }; - const statusColor = statusColors[crop.status as keyof typeof statusColors]; + // --------------------------------------------------------------- + // Derive styling based on crop status (ignoring case). + // --------------------------------------------------------------- + const statusKey = crop.status?.toLowerCase() || "default"; // Use camelCase status + const statusColor = statusColors[statusKey] || statusColors.default; + // --------------------------------------------------------------- + // Format metadata for display: creation date and area. + // --------------------------------------------------------------- + const displayDate = crop.createdAt ? new Date(crop.createdAt).toLocaleDateString() : "N/A"; // Use camelCase createdAt + const displayArea = typeof crop.landSize === "number" ? `${crop.landSize} ha` : "N/A"; // Use camelCase landSize + + // =================================================================== + // Render: Crop information card with clickable behavior. + // =================================================================== return ( + className={`w-full h-full flex flex-col overflow-hidden transition-all duration-200 hover:shadow-lg border-muted/60 bg-card dark:bg-card hover:bg-muted/10 dark:hover:bg-slate-700 cursor-pointer`}>
- {crop.status} + {crop.status || "Unknown"}
- {crop.plantedDate.toLocaleDateString()} + {displayDate}
- +
-
- -
+ {/* ... icon ... */}
-

{crop.name}

+

{crop.name}

{/* Use camelCase name */}

- {crop.variety} • {crop.area} + {crop.growthStage || "N/A"} • {displayArea} {/* Use camelCase growthStage */}

- - {crop.status !== "planned" && ( -
-
- Progress - {crop.progress}% -
- -
- )} - - {crop.status === "growing" && ( + {crop.growthStage && (
-
- - Health: {crop.healthScore}% +
+ + {/* Use camelCase growthStage */} + Stage: {crop.growthStage}
)}
- + diff --git a/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx index c57f3cd..bf1d5b6 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crop-dialog.tsx @@ -1,125 +1,301 @@ +// crop-dialog.tsx "use client"; -import { useState } from "react"; -import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import React, { useState, useMemo, useCallback, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useMapsLibrary } from "@vis.gl/react-google-maps"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Check, MapPin } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Check, + Sprout, + AlertTriangle, + Loader2, + CalendarDays, + Thermometer, + Droplets, + MapPin, + Maximize, +} from "lucide-react"; import { cn } from "@/lib/utils"; -import type { Crop } from "@/types"; -import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; -import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; - -interface Plant { - id: string; - name: string; - image: string; - growthTime: string; -} - -const plants: Plant[] = [ - { - id: "durian", - name: "Durian", - image: "/placeholder.svg?height=80&width=80", - growthTime: "4-5 months", - }, - { - id: "mango", - name: "Mango", - image: "/placeholder.svg?height=80&width=80", - growthTime: "3-4 months", - }, - { - id: "coconut", - name: "Coconut", - image: "/placeholder.svg?height=80&width=80", - growthTime: "5-6 months", - }, -]; +// Import the updated/new types +import type { Cropland, GeoFeatureData, GeoPosition } from "@/types"; +import { PlantResponse } from "@/api/plant"; +import { getPlants } from "@/api/plant"; +// Import the map component and the ShapeData type (ensure ShapeData in types.ts matches this) +import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing"; interface CropDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onSubmit: (data: Partial) => Promise; + onSubmit: (data: Partial) => Promise; + isSubmitting: boolean; } -export function CropDialog({ open, onOpenChange, onSubmit }: CropDialogProps) { - const [selectedPlant, setSelectedPlant] = useState(null); - const [location, setLocation] = useState({ lat: 13.7563, lng: 100.5018 }); // Bangkok coordinates +export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropDialogProps) { + // --- State --- + const [selectedPlantUUID, setSelectedPlantUUID] = useState(null); + // State to hold the structured GeoFeature data + const [geoFeature, setGeoFeature] = useState(null); + const [calculatedArea, setCalculatedArea] = useState(null); // Keep for display + // --- Load Google Maps Geometry Library --- + const geometryLib = useMapsLibrary("geometry"); + + // --- Fetch Plants --- + const { + data: plantData, + isLoading: isLoadingPlants, + isError: isErrorPlants, + error: errorPlants, + } = useQuery({ + queryKey: ["plants"], + queryFn: getPlants, + staleTime: 1000 * 60 * 60, + refetchOnWindowFocus: false, + }); + const plants = useMemo(() => plantData?.plants || [], [plantData]); + const selectedPlant = useMemo(() => { + return plants.find((p) => p.uuid === selectedPlantUUID); + }, [plants, selectedPlantUUID]); + + // --- Reset State on Dialog Close --- + useEffect(() => { + if (!open) { + setSelectedPlantUUID(null); + setGeoFeature(null); // Reset geoFeature state + setCalculatedArea(null); + } + }, [open]); + + // --- Map Interaction Handler --- + const handleShapeDrawn = useCallback( + (data: ShapeData) => { + console.log("Shape drawn:", data); + if (!geometryLib) { + console.warn("Geometry library not loaded yet."); + return; + } + + let feature: GeoFeatureData | null = null; + let area: number | null = null; + + // Helper to ensure path points are valid GeoPositions + const mapPath = (path?: { lat: number; lng: number }[]): GeoPosition[] => + (path || []).map((p) => ({ lat: p.lat, lng: p.lng })); + + // Helper to ensure position is a valid GeoPosition + const mapPosition = (pos?: { lat: number; lng: number }): GeoPosition | null => + pos ? { lat: pos.lat, lng: pos.lng } : null; + + if (data.type === "polygon" && data.path && data.path.length > 0) { + const geoPath = mapPath(data.path); + feature = { type: "polygon", path: geoPath }; + // Use original path for calculation if library expects {lat, lng} + area = geometryLib.spherical.computeArea(data.path); + console.log("Polygon drawn, Area:", area, "m²"); + } else if (data.type === "polyline" && data.path && data.path.length > 0) { + const geoPath = mapPath(data.path); + feature = { type: "polyline", path: geoPath }; + area = null; + console.log("Polyline drawn, Path:", data.path); + } else if (data.type === "marker" && data.position) { + const geoPos = mapPosition(data.position); + if (geoPos) { + feature = { type: "marker", position: geoPos }; + } + area = null; + console.log("Marker drawn at:", data.position); + } else { + console.log(`Ignoring shape type: ${data.type} or empty path/position`); + feature = null; + area = null; + } + + setGeoFeature(feature); + setCalculatedArea(area); + }, + [geometryLib] // Depend on geometryLib + ); + + // --- Submit Handler --- const handleSubmit = async () => { - if (!selectedPlant) return; + // Check for geoFeature instead of just drawnPath + if (!selectedPlantUUID || !geoFeature) { + alert("Please select a plant and define a feature (marker, polygon, or polyline) on the map."); + return; + } + // selectedPlant is derived from state using useMemo + if (!selectedPlant) { + alert("Selected plant not found."); // Should not happen if UUID is set + return; + } - await onSubmit({ - name: plants.find((p) => p.id === selectedPlant)?.name || "", - plantedDate: new Date(), - status: "planned", - }); + const cropData: Partial = { + // Default name, consider making this editable + name: `${selectedPlant.name} Field ${Math.floor(100 + Math.random() * 900)}`, + plantId: selectedPlant.uuid, + status: "planned", // Default status + // Use calculatedArea if available (only for polygons), otherwise maybe 0 + // The backend might ignore this if it calculates based on GeoFeature + landSize: calculatedArea ?? 0, + growthStage: "Planned", // Default growth stage + priority: 1, // Default priority + geoFeature: geoFeature, // Add the structured geoFeature data + // FarmID will be added in the page component mutationFn + }; - setSelectedPlant(null); - onOpenChange(false); + console.log("Submitting Cropland Data:", cropData); + + try { + await onSubmit(cropData); + // State reset handled by useEffect watching 'open' + } catch (error) { + console.error("Submission failed in dialog:", error); + // Optionally show an error message to the user within the dialog + } }; + // --- Render --- return ( - - - - -
- {/* Left side - Plant Selection */} -
-

Select Plant to Grow

-
- {plants.map((plant) => ( - setSelectedPlant(plant.id)}> -
- {plant.name} -
-
-

{plant.name}

- {selectedPlant === plant.id && } -
-

Growth time: {plant.growthTime}

-
-
-
- ))} -
-
+ + + Create New Cropland + + Select a plant and draw the cropland boundary or mark its location on the map. + + - {/* Right side - Map */} -
-
-
- +
+ {/* Left Side: Plant Selection */} +
+

1. Select Plant

+ {/* Plant selection UI */} + {isLoadingPlants && ( +
+ + Loading plants...
-
+ )} + {isErrorPlants && ( +
+ + Error loading plants: {(errorPlants as Error)?.message} +
+ )} + {!isLoadingPlants && !isErrorPlants && plants.length === 0 && ( +
No plants available.
+ )} + {!isLoadingPlants && !isErrorPlants && plants.length > 0 && ( +
+ {plants.map((plant) => ( + setSelectedPlantUUID(plant.uuid)}> + +
+
+ +
+
+
+

+ {plant.name} ({plant.variety}) +

+ {selectedPlantUUID === plant.uuid && ( + + )} +
+
+

+ Maturity: ~{plant.daysToMaturity ?? "N/A"} days +

+

+ Temp: {plant.optimalTemp ?? "N/A"}°C +

+

+ Water: {plant.waterNeeds ?? "N/A"} +

+
+
+
+
+
+ ))} +
+ )}
- {/* Footer */} -
-
- - + {/* Right Side: Map */} +
+

2. Define Boundary / Location

+
+ + + {/* Display feedback based on drawn shape */} + {geoFeature?.type === "polygon" && calculatedArea !== null && ( +
+ + Area: {calculatedArea.toFixed(2)} m² +
+ )} + {geoFeature?.type === "polyline" && geoFeature.path && ( +
+ + Boundary path defined ({geoFeature.path.length} points). +
+ )} + {geoFeature?.type === "marker" && geoFeature.position && ( +
+ + Marker set at {geoFeature.position.lat.toFixed(4)}, {geoFeature.position.lng.toFixed(4)}. +
+ )} + {!geometryLib && ( +
+ Loading map tools... +
+ )}
+

+ Use the drawing tools (Polygon , Polyline{" "} + , Marker ) above + the map. Area is calculated for polygons. +

+ + {/* Dialog Footer */} + + + {/* Disable submit if no plant OR no feature is selected */} + +
); diff --git a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/analytics-dialog.tsx b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/analytics-dialog.tsx index c7ea5f2..2ba8a75 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/analytics-dialog.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/analytics-dialog.tsx @@ -4,12 +4,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/u import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { LineChart, Sprout, Droplets, Sun } from "lucide-react"; -import type { Crop, CropAnalytics } from "@/types"; +import type { CropAnalytics, Cropland } from "@/types"; interface AnalyticsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - crop: Crop; + crop: Cropland; analytics: CropAnalytics; } diff --git a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx index 4c444ab..461928b 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/crops/[cropId]/page.tsx @@ -1,30 +1,29 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; +import React, { useMemo, useState } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, - Sprout, LineChart, - MessageSquare, Settings, Droplets, Sun, ThermometerSun, Timer, ListCollapse, - Calendar, Leaf, CloudRain, Wind, + Home, + ChevronRight, + AlertTriangle, + Loader2, + LeafIcon, + History, + Bot, } from "lucide-react"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - CardDescription, -} from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; @@ -32,56 +31,121 @@ import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { ChatbotDialog } from "./chatbot-dialog"; import { AnalyticsDialog } from "./analytics-dialog"; -import { - HoverCard, - HoverCardContent, - HoverCardTrigger, -} from "@/components/ui/hover-card"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import type { Crop, CropAnalytics } from "@/types"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import type { Cropland, CropAnalytics, Farm } from "@/types"; +import { getFarm } from "@/api/farm"; +import { getPlants, PlantResponse } from "@/api/plant"; +import { getCropById, fetchCropAnalytics } from "@/api/crop"; import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; -import { fetchCropById, fetchAnalyticsByCropId } from "@/api/farm"; -interface CropDetailPageParams { - farmId: string; - cropId: string; -} - -export default function CropDetailPage({ - params, -}: { - params: Promise; -}) { +export default function CropDetailPage() { const router = useRouter(); - const [crop, setCrop] = useState(null); - const [analytics, setAnalytics] = useState(null); + const params = useParams<{ farmId: string; cropId: string }>(); + const { farmId, cropId } = params; + const [isChatOpen, setIsChatOpen] = useState(false); const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); - useEffect(() => { - async function fetchData() { - const resolvedParams = await params; - const cropData = await fetchCropById(resolvedParams.cropId); - const analyticsData = await fetchAnalyticsByCropId(resolvedParams.cropId); - setCrop(cropData); - setAnalytics(analyticsData); - } - fetchData(); - }, [params]); + // --- Fetch Farm Data --- + const { data: farm, isLoading: isLoadingFarm } = useQuery({ + queryKey: ["farm", farmId], + queryFn: () => getFarm(farmId), + enabled: !!farmId, + staleTime: 5 * 60 * 1000, + }); - if (!crop || !analytics) { + // --- Fetch Cropland Data --- + const { + data: cropland, + isLoading: isLoadingCropland, + isError: isErrorCropland, + error: errorCropland, + } = useQuery({ + queryKey: ["crop", cropId], + queryFn: () => getCropById(cropId), + enabled: !!cropId, + staleTime: 60 * 1000, + }); + + // --- Fetch All Plants Data --- + const { + data: plantData, + isLoading: isLoadingPlants, + isError: isErrorPlants, + error: errorPlants, + } = useQuery({ + queryKey: ["plants"], + queryFn: getPlants, + staleTime: 1000 * 60 * 60, + refetchOnWindowFocus: false, + }); + + // --- Derive specific Plant --- + const plant = useMemo(() => { + if (!cropland?.plantId || !plantData?.plants) return null; + return plantData.plants.find((p) => p.uuid === cropland.plantId); + }, [cropland, plantData]); + + // --- Fetch Crop Analytics Data --- + const { + data: analytics, // Type is CropAnalytics | null + isLoading: isLoadingAnalytics, + isError: isErrorAnalytics, + error: errorAnalytics, + } = useQuery({ + queryKey: ["cropAnalytics", cropId], + queryFn: () => fetchCropAnalytics(cropId), + enabled: !!cropId, + staleTime: 5 * 60 * 1000, + }); + + // --- Combined Loading and Error States --- + const isLoading = isLoadingFarm || isLoadingCropland || isLoadingPlants || isLoadingAnalytics; + const isError = isErrorCropland || isErrorPlants || isErrorAnalytics; // Prioritize crop/analytics errors + const error = errorCropland || errorPlants || errorAnalytics; + + // --- Loading State --- + if (isLoading) { return (
- Loading... + + Loading crop details...
); } + // --- Error State --- + if (isError || !cropland) { + console.error("Error loading crop details:", error); + return ( +
+ + + + Error Loading Crop Details + + {isErrorCropland + ? `Crop with ID ${cropId} not found or could not be loaded.` + : (error as Error)?.message || "An unexpected error occurred."} + + +
+ ); + } + + // --- Data available, render page --- const healthColors = { - good: "text-green-500 bg-green-50 dark:bg-green-900", - warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900", - critical: "text-red-500 bg-red-50 dark:bg-red-900", + good: "text-green-500 bg-green-50 dark:bg-green-900 border-green-200", + warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900 border-yellow-200", + critical: "text-red-500 bg-red-50 dark:bg-red-900 border-red-200", }; + const healthStatus = analytics?.plantHealth || "good"; const quickActions = [ { @@ -93,7 +157,7 @@ export default function CropDetailPage({ }, { title: "Chat Assistant", - icon: MessageSquare, + icon: Bot, description: "Get help and advice", onClick: () => setIsChatOpen(true), color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300", @@ -102,96 +166,89 @@ export default function CropDetailPage({ title: "Crop Details", icon: ListCollapse, description: "View detailed information", - onClick: () => console.log("Details clicked"), - color: - "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300", + onClick: () => console.log("Details clicked - Placeholder"), + color: "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300", }, { title: "Settings", icon: Settings, description: "Configure crop settings", - onClick: () => console.log("Settings clicked"), + onClick: () => console.log("Settings clicked - Placeholder"), color: "bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300", }, ]; + const plantedDate = cropland.createdAt ? new Date(cropland.createdAt) : null; + const daysToMaturity = plant?.daysToMaturity; // Use camelCase + const expectedHarvestDate = + plantedDate && daysToMaturity ? new Date(plantedDate.getTime() + daysToMaturity * 24 * 60 * 60 * 1000) : null; + + const growthProgress = analytics?.growthProgress ?? 0; // Get from analytics + const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize} ha` : "N/A"; // Use camelCase + return (
+ {/* Breadcrumbs */} + + {/* Header */}
- - - - - -
- - - - - - -
-

Growth Timeline

-

- Planted on {crop.plantedDate.toLocaleDateString()} -

-
- - - {Math.floor(analytics.growthProgress)}% Complete - - -
-
-
-
-
+ {/* Hover Card (removed for simplicity, add back if needed) */}
-

{crop.name}

+

{cropland.name}

{/* Use camelCase */}

- {crop.variety} • {crop.area} + {plant?.variety || "Unknown Variety"} • {displayArea} {/* Use camelCase */}

- - Health Score: {crop.healthScore}% - - - Growing + + {cropland.status} {/* Use camelCase */}
- {crop.expectedHarvest ? ( + {expectedHarvestDate ? (

- Expected harvest:{" "} - {crop.expectedHarvest.toLocaleDateString()} + Expected harvest: {expectedHarvestDate.toLocaleDateString()}

) : ( -

- Expected harvest date not available -

+

Expected harvest date not available

)}
@@ -208,138 +265,123 @@ export default function CropDetailPage({ ))}
{/* Environmental Metrics */} - + Environmental Conditions - - Real-time monitoring of growing conditions - + Real-time monitoring data
-
+
{[ { icon: ThermometerSun, label: "Temperature", - value: `${analytics.temperature}°C`, + value: analytics?.temperature ? `${analytics.temperature}°C` : "N/A", color: "text-orange-500 dark:text-orange-300", bg: "bg-orange-50 dark:bg-orange-900", }, { icon: Droplets, label: "Humidity", - value: `${analytics.humidity}%`, + value: analytics?.humidity ? `${analytics.humidity}%` : "N/A", color: "text-blue-500 dark:text-blue-300", bg: "bg-blue-50 dark:bg-blue-900", }, { icon: Sun, label: "Sunlight", - value: `${analytics.sunlight}%`, + value: analytics?.sunlight ? `${analytics.sunlight}%` : "N/A", color: "text-yellow-500 dark:text-yellow-300", bg: "bg-yellow-50 dark:bg-yellow-900", }, { icon: Leaf, label: "Soil Moisture", - value: `${analytics.soilMoisture}%`, + value: analytics?.soilMoisture ? `${analytics.soilMoisture}%` : "N/A", color: "text-green-500 dark:text-green-300", bg: "bg-green-50 dark:bg-green-900", }, { icon: Wind, label: "Wind Speed", - value: analytics.windSpeed, + value: analytics?.windSpeed ?? "N/A", color: "text-gray-500 dark:text-gray-300", bg: "bg-gray-50 dark:bg-gray-900", }, { icon: CloudRain, label: "Rainfall", - value: analytics.rainfall, + value: analytics?.rainfall ?? "N/A", color: "text-indigo-500 dark:text-indigo-300", bg: "bg-indigo-50 dark:bg-indigo-900", }, ].map((metric) => ( - + -
-
- +
+
+
-

- {metric.label} -

-

- {metric.value} -

+

{metric.label}

+

{metric.value}

))}
- - {/* Growth Progress */}
Growth Progress - - {analytics.growthProgress}% - + {growthProgress}%
- + +

+ Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity. +

- {/* Next Action Card */} - +
-
- +
+
-

- Next Action Required -

+

Next Action Required

- {analytics.nextAction} -

-

- Due by{" "} - {analytics.nextActionDue.toLocaleDateString()} + {analytics?.nextAction || "Check crop status"}

+ {analytics?.nextActionDue && ( +

+ Due by {new Date(analytics.nextActionDue).toLocaleDateString()} +

+ )} + {!analytics?.nextAction && ( +

No immediate actions required.

+ )}
@@ -349,13 +391,18 @@ export default function CropDetailPage({ {/* Map Section */} - + - Field Map - View and manage crop location + Cropland Location / Boundary + Visual representation on the farm - - + + {/* TODO: Ensure GoogleMapWithDrawing correctly parses and displays GeoFeatureData */} +
@@ -363,83 +410,64 @@ export default function CropDetailPage({ {/* Right Column */}
{/* Nutrient Levels */} - + - Nutrient Levels - Current soil composition + + + Nutrient Levels + + Soil composition (if available)
{[ { name: "Nitrogen (N)", - value: analytics.nutrientLevels.nitrogen, + value: analytics?.nutrientLevels?.nitrogen, color: "bg-blue-500 dark:bg-blue-700", }, { name: "Phosphorus (P)", - value: analytics.nutrientLevels.phosphorus, + value: analytics?.nutrientLevels?.phosphorus, color: "bg-yellow-500 dark:bg-yellow-700", }, { name: "Potassium (K)", - value: analytics.nutrientLevels.potassium, + value: analytics?.nutrientLevels?.potassium, color: "bg-green-500 dark:bg-green-700", }, ].map((nutrient) => (
{nutrient.name} - - {nutrient.value}% - + {nutrient.value ?? "N/A"}%
))} + {!analytics?.nutrientLevels && ( +

Nutrient data not available.

+ )}
- {/* Recent Activity */} - + - Recent Activity - Latest updates and changes + + + Recent Activity + + Latest updates (placeholder) - {[...Array(5)].map((_, i) => ( -
-
-
- -
-
-

- { - [ - "Irrigation completed", - "Nutrient levels checked", - "Growth measurement taken", - "Pest inspection completed", - "Soil pH tested", - ][i] - } -

-

- 2 hours ago -

-
-
- {i < 4 && ( - - )} -
- ))} +
No recent activity logged.
@@ -447,38 +475,30 @@ export default function CropDetailPage({
{/* Dialogs */} - - + + {/* Ensure AnalyticsDialog uses the correct props */} + {analytics && ( + + )}
); } - -/** - * Helper component to render an activity icon based on the index. - */ -function Activity({ icon }: { icon: number }) { - const icons = [ - , - , - , - , - , - ]; - return icons[icon]; -} diff --git a/frontend/app/(sidebar)/farms/[farmId]/page.tsx b/frontend/app/(sidebar)/farms/[farmId]/page.tsx index 8c0d879..ab7bd2d 100644 --- a/frontend/app/(sidebar)/farms/[farmId]/page.tsx +++ b/frontend/app/(sidebar)/farms/[farmId]/page.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, MapPin, @@ -13,10 +14,11 @@ import { Loader2, Home, ChevronRight, - Droplets, Sun, - Wind, } from "lucide-react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useParams } from "next/navigation"; + import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { CropDialog } from "./crop-dialog"; @@ -24,105 +26,138 @@ import { CropCard } from "./crop-card"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { motion, AnimatePresence } from "framer-motion"; -import type { Farm, Crop } from "@/types"; -import { fetchFarmDetails } from "@/api/farm"; - -/** - * Used in Next.js; params is now a Promise and must be unwrapped with React.use() - */ -interface FarmDetailPageProps { - params: Promise<{ farmId: string }>; -} - -export default function FarmDetailPage({ params }: FarmDetailPageProps) { - // Unwrap the promised params using React.use() (experimental) - const resolvedParams = React.use(params); +import type { Farm, Cropland } from "@/types"; +import { getCropsByFarmId, createCrop, CropResponse } from "@/api/crop"; +import { getFarm } from "@/api/farm"; +// =================================================================== +// Page Component: FarmDetailPage +// - Manages farm details, crop listings, filter tabs, and crop creation. +// - Performs API requests via React Query. +// =================================================================== +export default function FarmDetailPage() { + // --------------------------------------------------------------- + // Routing and URL Params + // --------------------------------------------------------------- + const params = useParams<{ farmId: string }>(); + const farmId = params.farmId; const router = useRouter(); - const [farm, setFarm] = useState(null); - const [crops, setCrops] = useState([]); + const queryClient = useQueryClient(); + + // --------------------------------------------------------------- + // Local State for dialog and crop filter management + // --------------------------------------------------------------- const [isDialogOpen, setIsDialogOpen] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); const [activeFilter, setActiveFilter] = useState("all"); - // Fetch farm details on initial render using the resolved params - useEffect(() => { - async function loadFarmDetails() { - try { - setIsLoading(true); - setError(null); - const { farm, crops } = await fetchFarmDetails(resolvedParams.farmId); - setFarm(farm); - setCrops(crops); - } catch (err) { - if (err instanceof Error) { - if (err.message === "FARM_NOT_FOUND") { - router.push("/not-found"); - return; - } - setError(err.message); - } else { - setError("An unknown error occurred"); - } - } finally { - setIsLoading(false); - } - } + // --------------------------------------------------------------- + // Data fetching: Farm details and Crops using React Query. + // - See: https://tanstack.com/query + // --------------------------------------------------------------- + const { + data: farm, + isLoading: isLoadingFarm, + isError: isErrorFarm, + error: errorFarm, + } = useQuery({ + queryKey: ["farm", farmId], + queryFn: () => getFarm(farmId), + enabled: !!farmId, + staleTime: 60 * 1000, + }); - loadFarmDetails(); - }, [resolvedParams.farmId, router]); + const { + data: cropData, // Changed name to avoid conflict + isLoading: isLoadingCrops, + isError: isErrorCrops, + error: errorCrops, + } = useQuery({ + // Use CropResponse type + queryKey: ["crops", farmId], + queryFn: () => getCropsByFarmId(farmId), // Use updated API function name + enabled: !!farmId, + staleTime: 60 * 1000, + }); - /** - * Handles adding a new crop. - */ - const handleAddCrop = async (data: Partial) => { - try { - // Simulate API delay - await new Promise((resolve) => setTimeout(resolve, 800)); - - const newCrop: Crop = { - id: Math.random().toString(36).substr(2, 9), - farmId: farm!.id, - name: data.name!, - plantedDate: data.plantedDate!, - status: data.status!, - variety: data.variety || "Standard", - area: data.area || "0 hectares", - healthScore: data.status === "growing" ? 85 : 0, - progress: data.status === "growing" ? 10 : 0, - }; - - setCrops((prev) => [newCrop, ...prev]); - - // Update the farm's crop count - if (farm) { - setFarm({ ...farm, crops: farm.crops + 1 }); - } + // --------------------------------------------------------------- + // Mutation: Create Crop + // - After creation, invalidate queries to refresh data. + // --------------------------------------------------------------- + const croplands = useMemo(() => cropData?.croplands || [], [cropData]); + const mutation = useMutation({ + mutationFn: (newCropData: Partial) => createCrop({ ...newCropData, farmId: farmId }), // Pass farmId here + onSuccess: (newlyCreatedCrop) => { + console.log("Successfully created crop:", newlyCreatedCrop); + queryClient.invalidateQueries({ queryKey: ["crops", farmId] }); + queryClient.invalidateQueries({ queryKey: ["farm", farmId] }); // Invalidate farm too to update crop count potentially setIsDialogOpen(false); - } catch (err) { - setError("Failed to add crop. Please try again."); - } + }, + onError: (error) => { + console.error("Failed to add crop:", error); + // TODO: Show user-friendly error message (e.g., using toast) + }, + }); + + const handleAddCrop = async (data: Partial) => { + await mutation.mutateAsync(data); }; - // Filter crops based on the active filter - const filteredCrops = crops.filter((crop) => activeFilter === "all" || crop.status === activeFilter); + // --------------------------------------------------------------- + // Determine combined loading and error states from individual queries. + // --------------------------------------------------------------- + const isLoading = isLoadingFarm || isLoadingCrops; + const isError = isErrorFarm || isErrorCrops; + const error = errorFarm || errorCrops; - // Calculate crop counts grouped by status - const cropCounts = { - all: crops.length, - growing: crops.filter((crop) => crop.status === "growing").length, - planned: crops.filter((crop) => crop.status === "planned").length, - harvested: crops.filter((crop) => crop.status === "harvested").length, - }; + // --------------------------------------------------------------- + // Filter crops based on the active filter tab. + // --------------------------------------------------------------- + const filteredCrops = useMemo(() => { + // Renamed from filteredCrops + return croplands.filter( + (crop) => activeFilter === "all" || crop.status.toLowerCase() === activeFilter.toLowerCase() // Use camelCase status + ); + }, [croplands, activeFilter]); + // --------------------------------------------------------------- + // Calculate counts for each crop status to display in tabs. + // --------------------------------------------------------------- + const possibleStatuses = ["growing", "planned", "harvested", "fallow"]; // Use lowercase + const cropCounts = useMemo(() => { + return croplands.reduce( + (acc, crop) => { + const status = crop.status.toLowerCase(); // Use camelCase status + if (acc[status] !== undefined) { + acc[status]++; + } else { + acc["other"] = (acc["other"] || 0) + 1; // Count unknown statuses + } + acc.all++; + return acc; + }, + { all: 0, ...Object.fromEntries(possibleStatuses.map((s) => [s, 0])) } as Record + ); + }, [croplands]); + + // --------------------------------------------------------------- + // Derive the unique statuses from the crops list for the tabs. + // --------------------------------------------------------------- + const availableStatuses = useMemo(() => { + return ["all", ...new Set(croplands.map((crop) => crop.status.toLowerCase()))]; // Use camelCase status + }, [croplands]); + + // =================================================================== + // Render: Main page layout segmented into breadcrumbs, farm cards, + // crop management, and the crop add dialog. + // =================================================================== return (
- {/* Breadcrumbs */} + {/* ------------------------------ + Breadcrumbs Navigation Section + ------------------------------ */} - {/* Back button */} + {/* ------------------------------ + Back Navigation Button + ------------------------------ */} - {/* Error state */} - {error && ( + {/* ------------------------------ + Error and Loading States + ------------------------------ */} + {isError && !isLoadingFarm && !farm && ( - Error - {error} + Error Loading Farm + {(error as Error)?.message || "Could not load farm details."} + + )} + {isErrorCrops && ( + + + Error Loading Crops + {(errorCrops as Error)?.message || "Could not load crop data."} )} - - {/* Loading state */} {isLoading && (
@@ -168,22 +212,24 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {
)} - {/* Farm details */} - {!isLoading && !error && farm && ( + {/* ------------------------------ + Farm Details and Statistics + ------------------------------ */} + {!isLoadingFarm && !isErrorFarm && farm && ( <>
- {/* Farm info card */} + {/* Farm Info Card */}
- {farm.type} + {farm.farmType}
- Created {farm.createdAt.toLocaleDateString()} + Created {new Date(farm.createdAt).toLocaleDateString()}
@@ -194,7 +240,7 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {

{farm.name}

- {farm.location} + Lat: {farm.lat?.toFixed(4)}, Lon: {farm.lon?.toFixed(4)}
@@ -203,248 +249,141 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {

Total Area

-

{farm.area}

+

{farm.totalSize}

Total Crops

-

{farm.crops}

+

{isLoadingCrops ? "..." : cropCounts.all ?? 0}

-

Growing Crops

-

{cropCounts.growing}

+

Growing

+

{isLoadingCrops ? "..." : cropCounts.growing ?? 0}

Harvested

-

{cropCounts.harvested}

+

{isLoadingCrops ? "..." : cropCounts.harvested ?? 0}

- {/* Weather card */} + {/* Weather Overview Card */} - Current Conditions - Weather at your farm location + + Weather Overview + + Current conditions - -
-
-
- -
-
-

Temperature

-

{farm.weather?.temperature}°C

-
-
-
-
- -
-
-

Humidity

-

{farm.weather?.humidity}%

-
-
-
-
- -
-
-

Sunlight

-

{farm.weather?.sunlight}%

-
-
-
-
- -
-
-

Rainfall

-

{farm.weather?.rainfall}

-
-
+ +
+ Temperature + 25°C +
+
+ Humidity + 60% +
+
+ Wind + 10 km/h +
+
+ Rainfall (24h) + 2 mm
- {/* Crops section */} + {/* ------------------------------ + Crops Section: List and Filtering Tabs + ------------------------------ */}

- Crops + Crops / Croplands

-

Manage and monitor all crops in this farm

+

Manage and monitor all croplands in this farm

+ {mutation.isError && ( + + + Failed to Add Crop + + {(mutation.error as Error)?.message || "Could not add the crop. Please try again."} + + + )} - + - setActiveFilter("all")}> - All Crops ({cropCounts.all}) - - setActiveFilter("growing")}> - Growing ({cropCounts.growing}) - - setActiveFilter("planned")}> - Planned ({cropCounts.planned}) - - setActiveFilter("harvested")}> - Harvested ({cropCounts.harvested}) - + {availableStatuses.map((status) => ( + + {status === "all" ? "All" : status} ({isLoadingCrops ? "..." : cropCounts[status] ?? 0}) + + ))} - - {filteredCrops.length === 0 ? ( -
-
- -
-

No crops found

-

- {activeFilter === "all" - ? "You haven't added any crops to this farm yet." - : `No ${activeFilter} crops found. Try a different filter.`} -

- -
- ) : ( -
- - {filteredCrops.map((crop, index) => ( - - router.push(`/farms/${crop.farmId}/crops/${crop.id}`)} - /> - - ))} - -
- )} -
- - {/* Growing tab */} - - {filteredCrops.length === 0 ? ( -
-
- -
-

No growing crops

-

- You don't have any growing crops in this farm yet. -

- -
- ) : ( -
- - {filteredCrops.map((crop, index) => ( - - router.push(`/farms/${crop.farmId}/crops/${crop.id}`)} - /> - - ))} - -
- )} -
- - {/* Planned tab */} - - {filteredCrops.length === 0 ? ( -
-

No planned crops

-

- You don't have any planned crops in this farm yet. -

- -
- ) : ( -
- - {filteredCrops.map((crop, index) => ( - - router.push(`/farms/${crop.farmId}/crops/${crop.id}`)} - /> - - ))} - -
- )} -
- - {/* Harvested tab */} - - {filteredCrops.length === 0 ? ( -
-

No harvested crops

-

- You don't have any harvested crops in this farm yet. -

- -
- ) : ( -
- - {filteredCrops.map((crop, index) => ( - - router.push(`/farms/${crop.farmId}/crops/${crop.id}`)} - /> - - ))} - -
- )} -
+ {isLoadingCrops ? ( +
+ +
+ ) : isErrorCrops ? ( +
Failed to load crops.
+ ) : ( + availableStatuses.map((status) => ( + + {filteredCrops.length === 0 && activeFilter === status ? ( +
+
+ +
+

+ No {status === "all" ? "" : status} crops found +

+

+ {status === "all" + ? "You haven't added any crops to this farm yet." + : `No crops with status "${status}" found.`} +

+ +
+ ) : activeFilter === status && filteredCrops.length > 0 ? ( +
+ + {filteredCrops.map((crop, index) => ( + + router.push(`/farms/${farmId}/crops/${crop.uuid}`)} + /> + + ))} + +
+ ) : null} +
+ )) + )}
@@ -452,8 +391,16 @@ export default function FarmDetailPage({ params }: FarmDetailPageProps) {
- {/* Add Crop Dialog */} - + {/* ------------------------------ + Add Crop Dialog Component + - Passes the mutation state to display loading indicators. + ------------------------------ */} +
); } diff --git a/frontend/app/(sidebar)/farms/add-farm-form.tsx b/frontend/app/(sidebar)/farms/add-farm-form.tsx index 3a23e92..b01b6cf 100644 --- a/frontend/app/(sidebar)/farms/add-farm-form.tsx +++ b/frontend/app/(sidebar)/farms/add-farm-form.tsx @@ -7,39 +7,77 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { Loader2 } from "lucide-react"; import type { Farm } from "@/types"; +import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing"; +// =================================================================== +// Schema Definition: Validates form inputs using Zod +// See: https://zod.dev +// =================================================================== const farmFormSchema = z.object({ name: z.string().min(2, "Farm name must be at least 2 characters"), - location: z.string().min(2, "Location must be at least 2 characters"), + latitude: z + .number({ invalid_type_error: "Latitude must be a number" }) + .min(-90, "Invalid latitude") + .max(90, "Invalid latitude") + .refine((val) => val !== 0, { message: "Please select a location on the map." }), + longitude: z + .number({ invalid_type_error: "Longitude must be a number" }) + .min(-180, "Invalid longitude") + .max(180, "Invalid longitude") + .refine((val) => val !== 0, { message: "Please select a location on the map." }), type: z.string().min(1, "Please select a farm type"), area: z.string().optional(), }); +// =================================================================== +// Component Props Declaration +// =================================================================== export interface AddFarmFormProps { onSubmit: (data: Partial) => Promise; onCancel: () => void; } +// =================================================================== +// Component: AddFarmForm +// - Manages the creation of new farm records. +// - Uses React Hook Form with Zod for form validation. +// - Includes a map component for coordinate selection. +// =================================================================== export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) { + // --------------------------------------------------------------- + // State and Form Setup + // --------------------------------------------------------------- const [isSubmitting, setIsSubmitting] = useState(false); const form = useForm>({ resolver: zodResolver(farmFormSchema), defaultValues: { name: "", - location: "", + latitude: 0, // Defaults handled by validation (marker must be selected) + longitude: 0, type: "", area: "", }, }); + // --------------------------------------------------------------- + // Form Submission Handler + // - Converts form data to the expected Farm shape. + // --------------------------------------------------------------- const handleSubmit = async (values: z.infer) => { try { setIsSubmitting(true); - await onSubmit(values); + const farmData: Partial = { + name: values.name, + lat: values.latitude, + lon: values.longitude, + farmType: values.type, + totalSize: values.area, + }; + await onSubmit(farmData); form.reset(); } catch (error) { console.error("Error submitting form:", error); @@ -48,95 +86,184 @@ export function AddFarmForm({ onSubmit, onCancel }: AddFarmFormProps) { } }; + // --------------------------------------------------------------- + // Map-to-Form Coordination: Update coordinates from the map + // - Uses useCallback to preserve reference and optimize re-renders. + // --------------------------------------------------------------- + const handleShapeDrawn = useCallback( + (data: ShapeData) => { + // Log incoming shape data for debugging + console.log("Shape drawn in form:", data); + + // Only update the form if a single marker (i.e. point) is used + if (data.type === "marker") { + const { lat, lng } = data.position; + form.setValue("latitude", lat, { shouldValidate: true }); + form.setValue("longitude", lng, { shouldValidate: true }); + console.log(`Set form lat: ${lat}, lng: ${lng}`); + } else { + // Note: Only markers update coordinates. Other shapes could be handled later. + console.log(`Received shape type '${data.type}', but only 'marker' updates the form coordinates.`); + } + }, + [form] + ); + + // =================================================================== + // Render: Split into two main sections - Form and Map + // =================================================================== return ( -
- - ( - - Farm Name - - - - This is your farm's display name. - - - )} - /> +
+ {/* ============================== + Start of Form Section + ============================== */} +
+ + + {/* Farm Name Field */} + ( + + Farm Name + + + + This is your farm's display name. + + + )} + /> - ( - - Location - - - - City, region or specific address - - - )} - /> + {/* Coordinate Fields (Latitude & Longitude) */} +
+ ( + + Latitude + + + + + + )} + /> + ( + + Longitude + + + + + + )} + /> +
- ( - - Farm Type - - - - )} - /> + {/* Farm Type Selection */} + ( + + Farm Type + + + + )} + /> - ( - - Total Area (optional) - - - - The total size of your farm - - - )} - /> + {/* Total Area Field */} + ( + + Total Area (optional) + + + + + The total size of your farm (e.g., "15 rai", "10 hectares"). + + + + )} + /> -
- - + {/* Submit and Cancel Buttons */} +
+ + +
+ + +
+ {/* ============================== + End of Form Section + ============================== */} + + {/* ============================== + Start of Map Section + - Renders an interactive map for coordinate selection. + ============================== */} +
+ Farm Location (Draw marker on map) +
+
- - + + Select the marker tool above the map and click a location to set the latitude and longitude for your farm. + Only markers will update the coordinates. + +
+ {/* ============================== + End of Map Section + ============================== */} +
); } diff --git a/frontend/app/(sidebar)/farms/farm-card.tsx b/frontend/app/(sidebar)/farms/farm-card.tsx index 40afe47..00090ae 100644 --- a/frontend/app/(sidebar)/farms/farm-card.tsx +++ b/frontend/app/(sidebar)/farms/farm-card.tsx @@ -9,7 +9,7 @@ import type { Farm } from "@/types"; export interface FarmCardProps { variant: "farm" | "add"; - farm?: Farm; + farm?: Farm; // Use updated Farm type onClick?: () => void; } @@ -40,7 +40,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) { year: "numeric", month: "short", day: "numeric", - }).format(farm.createdAt); + }).format(new Date(farm.createdAt)); return ( @@ -49,7 +49,7 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) { - {farm.type} + {farm.farmType}
@@ -66,16 +66,16 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {

{farm.name}

- {farm.location} + {farm.lat}

Area

-

{farm.area}

+

{farm.totalSize}

Crops

-

{farm.crops}

+

{farm.crops ? farm.crops.length : 0}

diff --git a/frontend/app/(sidebar)/farms/page.tsx b/frontend/app/(sidebar)/farms/page.tsx index 957b321..cef3fe3 100644 --- a/frontend/app/(sidebar)/farms/page.tsx +++ b/frontend/app/(sidebar)/farms/page.tsx @@ -36,44 +36,59 @@ export default function FarmSetupPage() { const [isDialogOpen, setIsDialogOpen] = useState(false); const { - data: farms, + data: farms, // Type is Farm[] now isLoading, isError, error, } = useQuery({ + // Use Farm[] type queryKey: ["farms"], queryFn: fetchFarms, staleTime: 60 * 1000, }); const mutation = useMutation({ - mutationFn: (data: Partial) => createFarm(data), + // Pass the correct type to createFarm + mutationFn: (data: Partial>) => + createFarm(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["farms"] }); setIsDialogOpen(false); }, }); + // export interface Farm { + // CreatedAt: string; + // FarmType: string; + // Lat: number; + // Lon: number; + // Name: string; + // OwnerID: string; + // TotalSize: string; + // UUID: string; + // UpdatedAt: string; + // } + const filteredAndSortedFarms = (farms || []) .filter( (farm) => - (activeFilter === "all" || farm.type === activeFilter) && - (farm.name.toLowerCase().includes(searchQuery.toLowerCase()) || - farm.location.toLowerCase().includes(searchQuery.toLowerCase()) || - farm.type.toLowerCase().includes(searchQuery.toLowerCase())) + (activeFilter === "all" || farm.farmType === activeFilter) && // Use camelCase farmType + (farm.name.toLowerCase().includes(searchQuery.toLowerCase()) || // Use camelCase name + // farm.location is no longer a single string, use lat/lon if needed for search + farm.farmType.toLowerCase().includes(searchQuery.toLowerCase())) // Use camelCase farmType ) .sort((a, b) => { if (sortOrder === "newest") { - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); // Use camelCase createdAt } else if (sortOrder === "oldest") { - return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); // Use camelCase createdAt } else { - return a.name.localeCompare(b.name); + return a.name.localeCompare(b.name); // Use camelCase name } }); // Get distinct farm types. - const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.type))]; + const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType const handleAddFarm = async (data: Partial) => { await mutation.mutateAsync(data); @@ -121,6 +136,7 @@ export default function FarmSetupPage() { ))}
+ {/* DropdownMenu remains the same, Check icon was missing */} - - - - Add Inventory Item - Add a new plantation or fertilizer item to your inventory. - -
-
- - setItemName(e.target.value)} /> + <> + + + + + + + Add Inventory Item + + Add a new plantation or fertilizer item to your inventory. + + +
+
+ + setItemName(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + setItemQuantity(Number(e.target.value))} + /> +
+
+ + +
+
+ + + + + + + + + +
-
- - -
-
- - setItemCategory(e.target.value)} - /> -
-
- - setItemQuantity(Number(e.target.value))} - /> -
-
- - setItemUnit(e.target.value)} - /> -
-
- - - - - - - - - -
-
- - - - - + +
+ + +
+ {isSubmitted && ( +

+ {successMessage} You may close this window. +

+ )} + + {errorMessage && ( +

{errorMessage}

+ )} +
+
+
+ + + ); } diff --git a/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx b/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx index e53e37d..79679c9 100644 --- a/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx +++ b/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx @@ -1,63 +1,69 @@ -"use client"; - import { useState } from "react"; -import { CalendarIcon } from "lucide-react"; -import { format } from "date-fns"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; import { Dialog, + DialogTrigger, DialogContent, + DialogTitle, DialogDescription, DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -// import { deleteInventoryItem } from "@/api/inventory"; -// import type { DeleteInventoryItemInput } from "@/types"; +import { Button } from "@/components/ui/button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteInventoryItem } from "@/api/inventory"; -export function DeleteInventoryItem() { - const [date, setDate] = useState(); +export function DeleteInventoryItem({ id }: { id: string }) { const [open, setOpen] = useState(false); - const [itemName, setItemName] = useState(""); - const [itemType, setItemType] = useState(""); - const [itemCategory, setItemCategory] = useState(""); - const [itemQuantity, setItemQuantity] = useState(0); - const [itemUnit, setItemUnit] = useState(""); + const queryClient = useQueryClient(); - // const queryClient = useQueryClient(); + const { mutate: deleteItem, status } = useMutation({ + mutationFn: deleteInventoryItem, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); + }, + onError: (error) => { + console.error("Failed to delete item:", error); + }, + }); const handleDelete = () => { - // handle delete item + deleteItem(id.toString()); }; return ( - +
+ {/* delete confirmation dialog */} + + + + + + Confirm Deletion + + Are you sure you want to delete this item? This action cannot be + undone. + + + + + + + +
); } diff --git a/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx b/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx index b5d2a7a..9420996 100644 --- a/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx +++ b/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx @@ -1,12 +1,8 @@ "use client"; import { useState } from "react"; -import { CalendarIcon } from "lucide-react"; -import { format } from "date-fns"; import { useMutation, useQueryClient } from "@tanstack/react-query"; - import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; import { Dialog, DialogContent, @@ -18,11 +14,6 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { Select, SelectContent, @@ -32,65 +23,93 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -// import { updateInventoryItem } from "@/api/inventory"; -// import type { UpdateInventoryItemInput } from "@/types"; - -export interface EditInventoryItemProps { - id: string; - name: string; - category: string; - status: string; - type: string; - unit: string; - quantity: number; -} +import { + InventoryStatus, + InventoryItemCategory, + HarvestUnits, + UpdateInventoryItemInput, + EditInventoryItemInput, +} from "@/types"; +import { updateInventoryItem } from "@/api/inventory"; export function EditInventoryItem({ - id, - name, - category, - status, - type, - unit, - quantity, -}: EditInventoryItemProps) { + item, + fetchedInventoryStatus, + fetchedInventoryCategory, + fetchedHarvestUnits, +}: { + item: UpdateInventoryItemInput; + fetchedInventoryStatus: InventoryStatus[]; + fetchedInventoryCategory: InventoryItemCategory[]; + fetchedHarvestUnits: HarvestUnits[]; +}) { + // console.table(item); + // console.log(item.id); const [open, setOpen] = useState(false); - const [itemName, setItemName] = useState(name); - const [itemType, setItemType] = useState(type); - const [itemCategory, setItemCategory] = useState(category); - const [itemQuantity, setItemQuantity] = useState(quantity); - const [itemUnit, setItemUnit] = useState(unit); - const [itemStatus, setItemStatus] = useState(status); + const [itemName, setItemName] = useState(item.name); + const [itemCategory, setItemCategory] = useState( + fetchedInventoryCategory.find((x) => x.id === item.categoryId)?.name + ); - // const queryClient = useQueryClient(); + const [itemQuantity, setItemQuantity] = useState(item.quantity); - // const mutation = useMutation({ - // mutationFn: (item: UpdateInventoryItemInput) => UpdateInventoryItem(item), - // onSuccess: () => { - // // Invalidate queries to refresh inventory data. - // queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); - // // Reset form fields and close dialog. - // setItemName(""); - // setItemType(""); - // setItemCategory(""); - // setItemQuantity(0); - // setItemUnit(""); - // setDate(undefined); - // setOpen(false); - // }, - // }); + const [itemUnit, setItemUnit] = useState( + fetchedHarvestUnits.find((x) => x.id === item.unitId)?.name + ); + const [itemStatus, setItemStatus] = useState( + fetchedInventoryStatus.find((x) => x.id === item.statusId)?.name + ); + const [error, setError] = useState(null); + + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (x: EditInventoryItemInput) => updateInventoryItem(item.id, x), + onSuccess: () => { + // invalidate queries to refresh inventory data. + queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); + // reset form fields and close dialog. + setItemName(""); + setItemCategory(""); + setItemQuantity(0); + setItemUnit(""); + setOpen(false); + setItemStatus(""); + }, + }); + + // send edit request const handleEdit = () => { - // // Basic validation (you can extend this as needed) - // if (!itemName || !itemType || !itemCategory || !itemUnit) return; - // mutation.mutate({ - // name: itemName, - // type: itemType, - // category: itemCategory, - // quantity: itemQuantity, - // unit: itemUnit, - // }); + if (!itemName || !itemCategory || !itemUnit) { + setError("All fields are required. Please fill in missing details."); + return; + } + + const category = fetchedInventoryCategory.find( + (c) => c.name === itemCategory + )?.id; + const unit = fetchedHarvestUnits.find((u) => u.name === itemUnit)?.id; + const status = fetchedInventoryStatus.find( + (s) => s.name === itemStatus + )?.id; + + if (!category || !unit || !status) { + setError( + "Invalid category, unit, or status. Please select a valid option." + ); + return; + } + // console.log("Mutate called"); + // console.log(item.id); + mutation.mutate({ + name: itemName, + categoryId: category, + quantity: itemQuantity ?? 0, + unitId: unit, + statusId: status, + dateAdded: new Date().toISOString(), + }); }; return ( @@ -119,17 +138,20 @@ export function EditInventoryItem({
- - Type - Plantation - Fertilizer + Category + {fetchedInventoryCategory.map((categoryItem, _) => ( + + {categoryItem.name} + + ))} @@ -138,35 +160,22 @@ export function EditInventoryItem({ - Status - In Stock - Low Stock - Out Of Stock + {fetchedInventoryStatus.map((statusItem, _) => ( + + {statusItem.name} + + ))}
-
- - setItemCategory(e.target.value)} - /> -
-
+ {error &&

{error}

}
diff --git a/frontend/app/(sidebar)/inventory/page.tsx b/frontend/app/(sidebar)/inventory/page.tsx index 6080f18..74bf472 100644 --- a/frontend/app/(sidebar)/inventory/page.tsx +++ b/frontend/app/(sidebar)/inventory/page.tsx @@ -22,22 +22,27 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { TriangleAlertIcon } from "lucide-react"; import { Pagination, PaginationContent, PaginationItem, } from "@/components/ui/pagination"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Search } from "lucide-react"; import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa"; +import { fetchHarvestUnits } from "@/api/harvest"; import { Badge } from "@/components/ui/badge"; -import { fetchInventoryItems, fetchInventoryStatus } from "@/api/inventory"; -import { AddInventoryItem } from "./add-inventory-item"; import { - EditInventoryItem, - EditInventoryItemProps, -} from "./edit-inventory-item"; + fetchInventoryItems, + fetchInventoryStatus, + fetchInventoryCategory, +} from "@/api/inventory"; +import { AddInventoryItem } from "./add-inventory-item"; +import { EditInventoryItem } from "./edit-inventory-item"; import { DeleteInventoryItem } from "./delete-inventory-item"; +import { InventoryItem } from "@/types"; export default function InventoryPage() { const [sorting, setSorting] = useState([]); @@ -45,7 +50,8 @@ export default function InventoryPage() { pageIndex: 0, pageSize: 10, }); - + ////////////////////////////// + // query the necessary data for edit and etc. const { data: inventoryItems = [], isLoading: isItemLoading, @@ -55,6 +61,8 @@ export default function InventoryPage() { queryFn: fetchInventoryItems, staleTime: 60 * 1000, }); + // console.table(inventoryItems); + // console.log(inventoryItems); const { data: inventoryStatus = [], @@ -65,38 +73,98 @@ export default function InventoryPage() { queryFn: fetchInventoryStatus, staleTime: 60 * 1000, }); + + // console.log(inventoryStatus); + const { + data: inventoryCategory = [], + isLoading: isLoadingCategory, + isError: isErrorCategory, + } = useQuery({ + queryKey: ["inventoryCategory"], + queryFn: fetchInventoryCategory, + staleTime: 60 * 1000, + }); + const { + data: harvestUnits = [], + isLoading: isLoadingHarvestUnits, + isError: isErrorHarvestUnits, + } = useQuery({ + queryKey: ["harvestUnits"], + queryFn: fetchHarvestUnits, + staleTime: 60 * 1000, + }); + ////////////////////////////// // console.table(inventoryItems); - console.table(inventoryStatus); + // console.table(inventoryStatus); + // console.table(harvestUnits); + const [searchTerm, setSearchTerm] = useState(""); const filteredItems = useMemo(() => { return inventoryItems .map((item) => ({ ...item, - id: String(item.id), // Convert `id` to string here + status: { id: item.status.id, name: item.status.name }, + category: { id: item.category.id, name: item.category.name }, + unit: { id: item.unit.id, name: item.unit.name }, + fetchedInventoryStatus: inventoryStatus, + fetchedInventoryCategory: inventoryCategory, + fetchedHarvestUnits: harvestUnits, + lastUpdated: new Date(item.updatedAt).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: true, + }), })) .filter((item) => item.name.toLowerCase().includes(searchTerm.toLowerCase()) ); }, [inventoryItems, searchTerm]); + // prepare columns for table + const columns = [ { accessorKey: "name", header: "Name" }, - { accessorKey: "category", header: "Category" }, - { accessorKey: "quantity", header: "Quantity" }, - { accessorKey: "unit", header: "Unit" }, - { accessorKey: "lastUpdated", header: "Last Updated" }, + { + accessorKey: "category", + header: "Category", + cell: ({ row }: { row: { original: InventoryItem } }) => + row.original.category.name, + }, + { + accessorKey: "quantity", + header: "Quantity", + }, + { + accessorKey: "unit", + header: "Unit", + cell: ({ row }: { row: { original: InventoryItem } }) => + row.original.unit.name, + }, + { + accessorKey: "lastUpdated", + header: "Last Updated", + }, { accessorKey: "status", header: "Status", - cell: (info: { getValue: () => string }) => { - const status = info.getValue(); + cell: ({ row }: { row: { original: InventoryItem } }) => { + const status = row.original.status.name; - let statusClass = ""; // default status class + let statusClass = ""; - if (status === "Low Stock") { - statusClass = "bg-yellow-300"; // yellow for low stock - } else if (status === "Out Of Stock") { - statusClass = "bg-red-500 text-white"; // red for out of stock + if (status === "In Stock") { + statusClass = "bg-green-500 hover:bg-green-600 text-white"; + } else if (status === "Low Stock") { + statusClass = "bg-yellow-300 hover:bg-yellow-400"; + } else if (status === "Out of Stock") { + statusClass = "bg-red-500 hover:bg-red-600 text-white"; + } else if (status === "Expired") { + statusClass = "bg-gray-500 hover:bg-gray-600 text-white"; + } else if (status === "Reserved") { + statusClass = "bg-blue-500 hover:bg-blue-600 text-white"; } return ( @@ -109,15 +177,30 @@ export default function InventoryPage() { { accessorKey: "edit", header: "Edit", - cell: ({ row }: { row: { original: EditInventoryItemProps } }) => ( - + cell: ({ row }: { row: { original: InventoryItem } }) => ( + ), enableSorting: false, }, { accessorKey: "delete", header: "Delete", - cell: () => , + cell: ({ row }: { row: { original: InventoryItem } }) => ( + + ), enableSorting: false, }, ]; @@ -132,20 +215,61 @@ export default function InventoryPage() { onSortingChange: setSorting, onPaginationChange: setPagination, }); + const loadingStates = [ + isItemLoading, + isLoadingStatus, + isLoadingCategory, + isLoadingHarvestUnits, + ]; + const errorStates = [ + isItemError, + isErrorStatus, + isErrorCategory, + isErrorHarvestUnits, + ]; - if (isItemLoading || isLoadingStatus) + const isLoading = loadingStates.some((loading) => loading); + const isError = errorStates.some((error) => error); + if (isLoading) return (
Loading...
); - if (isItemError || isErrorStatus) + + if (isError) return (
Error loading inventory data.
); + if (inventoryItems.length === 0) { + return ( +
+ +
+ + No Inventory Data + +
+ You currently have no inventory items. Add a new item to get + started! +
+
+ +
+
+
+
+
+ ); + } + return (
@@ -159,7 +283,11 @@ export default function InventoryPage() { value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> - +
diff --git a/frontend/app/(sidebar)/layout.tsx b/frontend/app/(sidebar)/layout.tsx index 3e79b13..e097aaf 100644 --- a/frontend/app/(sidebar)/layout.tsx +++ b/frontend/app/(sidebar)/layout.tsx @@ -7,6 +7,9 @@ import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/s import DynamicBreadcrumb from "./dynamic-breadcrumb"; import { extractRoute } from "@/lib/utils"; import { usePathname } from "next/navigation"; +import { Toaster } from "@/components/ui/sonner"; +import { useForm, FormProvider } from "react-hook-form"; +import { APIProvider } from "@vis.gl/react-google-maps"; export default function AppLayout({ children, @@ -15,21 +18,27 @@ export default function AppLayout({ }>) { const pathname = usePathname(); const currentPathname = extractRoute(pathname); + const form = useForm(); return ( - - - -
-
- - - - -
-
- {children} -
-
+ + + + + +
+
+ + + + +
+
+ {children} + +
+
+
+
); } diff --git a/frontend/app/(sidebar)/setup/harvest-detail-form.tsx b/frontend/app/(sidebar)/setup/harvest-detail-form.tsx index c356b46..f276c6e 100644 --- a/frontend/app/(sidebar)/setup/harvest-detail-form.tsx +++ b/frontend/app/(sidebar)/setup/harvest-detail-form.tsx @@ -294,7 +294,12 @@ export default function HarvestDetailsForm({ )} />
- +
diff --git a/frontend/app/(sidebar)/setup/page.tsx b/frontend/app/(sidebar)/setup/page.tsx index 7c7badf..4febef6 100644 --- a/frontend/app/(sidebar)/setup/page.tsx +++ b/frontend/app/(sidebar)/setup/page.tsx @@ -1,123 +1,140 @@ "use client"; -import { SetStateAction, useEffect, useState } from "react"; +import { useState } from "react"; import PlantingDetailsForm from "./planting-detail-form"; import HarvestDetailsForm from "./harvest-detail-form"; -import { Separator } from "@/components/ui/separator"; import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; +import { Separator } from "@/components/ui/separator"; import { plantingDetailsFormSchema, harvestDetailsFormSchema, } from "@/schemas/application.schema"; import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; -type plantingSchema = z.infer; -type harvestSchema = z.infer; + +type PlantingSchema = z.infer; +type HarvestSchema = z.infer; + +const steps = [ + { title: "Step 1", description: "Planting Details" }, + { title: "Step 2", description: "Harvest Details" }, + { title: "Step 3", description: "Select Map Area" }, +]; export default function SetupPage() { - const [plantingDetails, setPlantingDetails] = useState( + const [step, setStep] = useState(1); + const [plantingDetails, setPlantingDetails] = useState( null ); - const [harvestDetails, setHarvestDetails] = useState( + const [harvestDetails, setHarvestDetails] = useState( null ); const [mapData, setMapData] = useState<{ lat: number; lng: number }[] | null>( null ); - // handle planting details submission - const handlePlantingDetailsChange = (data: plantingSchema) => { - setPlantingDetails(data); + const handleNext = () => { + if (step === 1 && !plantingDetails) { + toast.warning( + "Please complete the Planting Details before proceeding.", + { + action: { + label: "Close", + onClick: () => toast.dismiss(), + }, + } + ); + return; + } + if (step === 2 && !harvestDetails) { + toast.warning( + "Please complete the Harvest Details before proceeding.", + { + action: { + label: "Close", + onClick: () => toast.dismiss(), + }, + } + ); + return; + } + setStep((prev) => prev + 1); }; - // handle harvest details submission - const handleHarvestDetailsChange = (data: harvestSchema) => { - setHarvestDetails(data); + const handleBack = () => { + setStep((prev) => prev - 1); }; - // handle map area selection - const handleMapDataChange = (data: { lat: number; lng: number }[]) => { - setMapData((prevMapData) => { - if (prevMapData) { - return [...prevMapData, ...data]; - } else { - return data; - } - }); - }; - - // log the changes - useEffect(() => { - // console.log(plantingDetails); - // console.log(harvestDetails); - console.table(mapData); - }, [plantingDetails, harvestDetails, mapData]); - const handleSubmit = () => { - if (!plantingDetails || !harvestDetails || !mapData) { - alert("Please complete all sections before submitting."); + if (!mapData) { + toast.warning("Please select an area on the map before submitting.", { + action: { + label: "Close", + onClick: () => toast.dismiss(), + }, + }); return; } - const formData = { - plantingDetails, - harvestDetails, - mapData, - }; + console.log("Submitting:", { plantingDetails, harvestDetails, mapData }); - console.log("Form data to be submitted:", formData); + // send request to the server - fetch("/api/submit", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(formData), - }) - .then((response) => response.json()) - .then((data) => { - console.log("Response from backend:", data); - }) - .catch((error) => { - console.error("Error submitting form:", error); - }); }; return (
- {/* Planting Details Section */} -
-

Planting Details

-
- -
- + {/* Stepper Navigation */} +
+ {steps.map((item, index) => ( +
+
+ {index + 1} +
+ {item.title} + {item.description} +
+ ))}
- {/* Harvest Details Section */} -
-

Harvest Details

-
- -
- -
+ - {/* Map Section */} -
-
-

Map

-
- -
- -
-
+ {step === 1 && ( + <> +

Planting Details

+ + + )} - {/* Submit Button */} -
- + {step === 2 && ( + <> +

Harvest Details

+ + + )} + + {step === 3 && ( + <> +

Select Area on Map

+ + + )} + +
+ + + {step < 3 ? ( + + ) : ( + + )}
); diff --git a/frontend/app/(sidebar)/setup/planting-detail-form.tsx b/frontend/app/(sidebar)/setup/planting-detail-form.tsx index 13f7f5f..78a715b 100644 --- a/frontend/app/(sidebar)/setup/planting-detail-form.tsx +++ b/frontend/app/(sidebar)/setup/planting-detail-form.tsx @@ -23,14 +23,16 @@ import { import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; +import { useRef } from "react"; type plantingSchema = z.infer; export default function PlantingDetailsForm({ onChange, }: { - onChange: (data: plantingSchema) => void; + onChange: (data: plantingSchema) => void; }) { + const formRef = useRef(null); const form = useForm({ resolver: zodResolver(plantingDetailsFormSchema), defaultValues: { @@ -57,6 +59,7 @@ export default function PlantingDetailsForm({
- +
diff --git a/frontend/app/auth/signin/forgot-password-modal.tsx b/frontend/app/auth/signin/forgot-password-modal.tsx index 14f0901..226d24f 100644 --- a/frontend/app/auth/signin/forgot-password-modal.tsx +++ b/frontend/app/auth/signin/forgot-password-modal.tsx @@ -10,7 +10,6 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import Link from "next/link"; import { Label } from "@/components/ui/label"; export default function ForgotPasswordModal() { 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"); diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx index f3b460e..e0d48b4 100644 --- a/frontend/app/auth/signin/page.tsx +++ b/frontend/app/auth/signin/page.tsx @@ -18,7 +18,6 @@ import { loginUser } from "@/api/authentication"; import { SessionContext } from "@/context/SessionContext"; import { Eye, EyeOff, Leaf, ArrowRight, AlertCircle, Loader2 } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { ThemeToggle } from "@/components/theme-toggle"; export default function SigninPage() { const [serverError, setServerError] = useState(null); diff --git a/frontend/app/auth/signup/page.tsx b/frontend/app/auth/signup/page.tsx index ed0cb02..24b28a4 100644 --- a/frontend/app/auth/signup/page.tsx +++ b/frontend/app/auth/signup/page.tsx @@ -14,9 +14,18 @@ import type { z } from "zod"; import { useRouter } from "next/navigation"; import { registerUser } from "@/api/authentication"; import { SessionContext } from "@/context/SessionContext"; -import { Eye, EyeOff, Leaf, ArrowRight, AlertCircle, Loader2, Check } from "lucide-react"; +import { + Eye, + EyeOff, + Leaf, + ArrowRight, + AlertCircle, + Loader2, + Check, +} from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Progress } from "@/components/ui/progress"; +import { GoogleSigninButton } from "../signin/google-oauth"; export default function SignupPage() { const [serverError, setServerError] = useState(null); @@ -75,7 +84,9 @@ export default function SignupPage() { const data = await registerUser(values.email, values.password); if (!data) { - setServerError("An error occurred while registering. Please try again."); + setServerError( + "An error occurred while registering. Please try again." + ); throw new Error("No data received from the server."); } @@ -120,9 +131,12 @@ export default function SignupPage() {
-

Join the farming revolution

+

+ Join the farming revolution +

- Create your account today and discover how ForFarm can help you optimize your agricultural operations. + Create your account today and discover how ForFarm can help + you optimize your agricultural operations.

{[ @@ -148,11 +162,18 @@ export default function SignupPage() {
{/* Theme Selector Placeholder */} -
Theme Selector Placeholder
+
+ Theme Selector Placeholder +
- +
@@ -160,7 +181,10 @@ export default function SignupPage() {

Create your account

Already have an account?{" "} - + Sign in

@@ -184,7 +208,10 @@ export default function SignupPage() {
{/* Email */}
-