diff --git a/backend/internal/api/analytic.go b/backend/internal/api/analytic.go index ebb7228..1e3c867 100644 --- a/backend/internal/api/analytic.go +++ b/backend/internal/api/analytic.go @@ -18,22 +18,43 @@ func (a *api) registerAnalyticsRoutes(_ chi.Router, api huma.API) { huma.Register(api, huma.Operation{ OperationID: "getFarmAnalytics", Method: http.MethodGet, - Path: prefix + "/farm/{farm_id}", + 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:"farm_id" required:"true" doc:"UUID of the farm to get analytics for" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"` + 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 { @@ -54,6 +75,7 @@ func (a *api) getFarmAnalyticsHandler(ctx context.Context, input *GetFarmAnalyti 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.") @@ -64,3 +86,49 @@ func (a *api) getFarmAnalyticsHandler(ctx context.Context, input *GetFarmAnalyti } 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/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 8a3053b..477c360 100644 --- a/backend/internal/api/crop.go +++ b/backend/internal/api/crop.go @@ -2,6 +2,7 @@ package api import ( "context" + "database/sql" "encoding/json" "errors" "net/http" @@ -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) @@ -53,41 +54,54 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { type GetCroplandsOutput struct { Body struct { Croplands []domain.Cropland `json:"croplands"` - } `json:"body"` + } } type GetCroplandByIDOutput struct { Body struct { Cropland domain.Cropland `json:"cropland"` - } `json:"body"` + } } type CreateOrUpdateCroplandInput struct { - 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" doc:"GeoJSON-like feature object (marker, polygon, etc.)" example:"{\"type\":\"marker\",\"position\":{\"lat\":13.84,\"lng\":100.48}}"` - } `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"` + } } type CreateOrUpdateCroplandOutput struct { Body struct { Cropland domain.Cropland `json:"cropland"` - } `json:"body"` + } } -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{} 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 @@ -95,25 +109,50 @@ func (a *api) getAllCroplandsHandler(ctx context.Context, input *struct{}) (*Get } 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{} if input.UUID == "" { return nil, huma.Error400BadRequest("UUID parameter is required") } - _, 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") } 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 @@ -121,22 +160,48 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct { } 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{} if input.FarmID == "" { - return nil, huma.Error400BadRequest("FarmID parameter is required") + return nil, huma.Error400BadRequest("farm_id parameter is required") } - _, 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") } 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 @@ -144,8 +209,14 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct } 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{} + // --- Input Validation --- if input.Body.Name == "" { return nil, huma.Error400BadRequest("name is required") } @@ -153,31 +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 formats if input.Body.UUID != "" { 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 plant_id UUID format") + return nil, huma.Error400BadRequest("invalid plantId UUID format") } - if _, err := uuid.FromString(input.Body.FarmID); err != nil { + 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 geo_feature") + 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") + } + } + + // --- Prepare and Save Cropland --- cropland := &domain.Cropland{ UUID: input.Body.UUID, Name: input.Body.Name, @@ -190,11 +295,15 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr GeoFeature: input.Body.GeoFeature, } - 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, huma.Error500InternalServerError("failed to save cropland") + 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") } + 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 89afbcc..7d611cb 100644 --- a/backend/internal/api/farm.go +++ b/backend/internal/api/farm.go @@ -2,6 +2,8 @@ package api import ( "context" + "database/sql" + "errors" "fmt" "net/http" @@ -25,7 +27,7 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) { huma.Register(api, huma.Operation{ OperationID: "getFarmByID", Method: http.MethodGet, - Path: prefix + "/{farm_id}", + Path: prefix + "/{farmId}", Tags: tags, }, a.getFarmByIDHandler) @@ -39,14 +41,14 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) { huma.Register(api, huma.Operation{ OperationID: "updateFarm", Method: http.MethodPut, - Path: prefix + "/{farm_id}", + Path: prefix + "/{farmId}", Tags: tags, }, a.updateFarmHandler) huma.Register(api, huma.Operation{ OperationID: "deleteFarm", Method: http.MethodDelete, - Path: prefix + "/{farm_id}", + Path: prefix + "/{farmId}", Tags: tags, }, a.deleteFarmHandler) } @@ -55,15 +57,14 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) { // Input and Output types // -// CreateFarmInput contains the request data for creating a new farm. 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"` - FarmType string `json:"FarmType,omitempty"` - TotalSize string `json:"TotalSize,omitempty"` + 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"` } } @@ -78,38 +79,37 @@ type GetAllFarmsInput struct { } type GetAllFarmsOutput struct { - Body []domain.Farm + 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"` } -// UpdateFarmInput uses pointer types for optional/nullable fields. type UpdateFarmInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - FarmID string `path:"farm_id"` + 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"` + 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"` } } type UpdateFarmOutput struct { - Body domain.Farm + 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 { @@ -125,7 +125,7 @@ type DeleteFarmOutput struct { 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) } farm := &domain.Farm{ @@ -136,11 +136,21 @@ func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*C TotalSize: input.Body.TotalSize, OwnerID: userID, } - fmt.Println(farm) + + // 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 { - return nil, huma.Error500InternalServerError("failed to create farm", err) + 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"` @@ -151,29 +161,42 @@ func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*C func (a *api) getAllFarmsHandler(ctx context.Context, input *GetAllFarmsInput) (*GetAllFarmsOutput, error) { userID, err := a.getUserIDFromHeader(input.Header) if err != nil { - return nil, err + return nil, huma.Error401Unauthorized("Authentication failed", err) } farms, err := a.farmRepo.GetByOwnerID(ctx, userID) if err != nil { - return nil, err + 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, err + return nil, huma.Error401Unauthorized("Authentication failed", err) } farm, err := a.farmRepo.GetByID(ctx, input.FarmID) if err != nil { - return nil, err + 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 { - return nil, huma.Error401Unauthorized("unauthorized") + 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 @@ -182,60 +205,106 @@ func (a *api) getFarmByIDHandler(ctx context.Context, input *GetFarmByIDInput) ( func (a *api) updateFarmHandler(ctx context.Context, input *UpdateFarmInput) (*UpdateFarmOutput, error) { userID, err := a.getUserIDFromHeader(input.Header) if err != nil { - return nil, err + return nil, huma.Error401Unauthorized("Authentication failed", err) } farm, err := a.farmRepo.GetByID(ctx, input.FarmID) if err != nil { - return nil, err + 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 { - return nil, huma.Error401Unauthorized("unauthorized") + 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") } - if input.Body.Name != "" { - farm.Name = input.Body.Name + // 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 { + if input.Body.Lat != nil && *input.Body.Lat != farm.Lat { farm.Lat = *input.Body.Lat + updated = true } - if input.Body.Lon != nil { + if input.Body.Lon != nil && *input.Body.Lon != farm.Lon { farm.Lon = *input.Body.Lon + updated = true } - if input.Body.FarmType != nil { + if input.Body.FarmType != nil && *input.Body.FarmType != farm.FarmType { farm.FarmType = *input.Body.FarmType + updated = true } - if input.Body.TotalSize != nil { + 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 { - return nil, err + a.logger.Error("Failed to update farm in database", "farmId", input.FarmID, "error", err) + return nil, huma.Error500InternalServerError("Failed to update farm") } - return &UpdateFarmOutput{Body: *farm}, nil + 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, err + return nil, huma.Error401Unauthorized("Authentication failed", err) } farm, err := a.farmRepo.GetByID(ctx, input.FarmID) if err != nil { - return nil, err + 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 { - return nil, huma.Error401Unauthorized("unauthorized") + 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 { - return nil, err + 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"` 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/domain/analytics.go b/backend/internal/domain/analytics.go index fa98cc6..ea08a12 100644 --- a/backend/internal/domain/analytics.go +++ b/backend/internal/domain/analytics.go @@ -6,42 +6,58 @@ import ( ) type FarmAnalytics struct { - FarmID string `json:"farm_id"` - FarmName string `json:"farm_name"` - OwnerID string `json:"owner_id"` - FarmType *string `json:"farm_type,omitempty"` - TotalSize *string `json:"total_size,omitempty"` + 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:"total_items"` - LowStockCount int `json:"low_stock_count"` - LastUpdated *time.Time `json:"last_updated,omitempty"` - } `json:"inventory_info"` + TotalItems int `json:"totalItems"` + LowStockCount int `json:"lowStockCount"` + LastUpdated *time.Time `json:"lastUpdated,omitempty"` + } `json:"inventoryInfo"` CropInfo struct { - TotalCount int `json:"total_count"` - GrowingCount int `json:"growing_count"` - LastUpdated *time.Time `json:"last_updated,omitempty"` - } `json:"crop_info"` - OverallStatus *string `json:"overall_status,omitempty"` - AnalyticsLastUpdated time.Time `json:"analytics_last_updated"` + 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:"crop_id"` - CropName string `json:"crop_name"` - FarmID string `json:"farm_id"` - PlantName string `json:"plant_name"` - Variety *string `json:"variety,omitempty"` - CurrentStatus string `json:"current_status"` - GrowthStage string `json:"growth_stage"` - LandSize float64 `json:"land_size"` - LastUpdated time.Time `json:"last_updated"` + 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"` + LandSize float64 `json:"landSize"` + LastUpdated time.Time `json:"lastUpdated"` + Temperature *float64 `json:"temperature,omitempty"` + Humidity *float64 `json:"humidity,omitempty"` + SoilMoisture *float64 `json:"soilMoisture,omitempty"` + Sunlight *float64 `json:"sunlight,omitempty"` + WindSpeed *string `json:"windSpeed,omitempty"` + Rainfall *string `json:"rainfall,omitempty"` + GrowthProgress int `json:"growthProgress"` + 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 diff --git a/backend/internal/domain/cropland.go b/backend/internal/domain/cropland.go index b15dc06..e45b77d 100644 --- a/backend/internal/domain/cropland.go +++ b/backend/internal/domain/cropland.go @@ -9,17 +9,17 @@ import ( ) type Cropland struct { - UUID string - Name string - Status string - Priority int - LandSize float64 - GrowthStage string - PlantID string - FarmID string - GeoFeature json.RawMessage - 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 { diff --git a/backend/internal/domain/farm.go b/backend/internal/domain/farm.go index d349d00..c2da327 100644 --- a/backend/internal/domain/farm.go +++ b/backend/internal/domain/farm.go @@ -10,13 +10,13 @@ import ( type Farm struct { UUID string `json:"uuid"` Name string `json:"name"` - Lat float64 `json:"latitude"` - Lon float64 `json:"longitude"` - FarmType string `json:"farm_type,omitempty"` - TotalSize string `json:"total_size,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - OwnerID string `json:"owner_id"` + 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"` } 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 index 9fb33d2..d375f7b 100644 --- a/backend/internal/domain/weather.go +++ b/backend/internal/domain/weather.go @@ -6,14 +6,14 @@ import ( ) type WeatherData struct { - TempCelsius *float64 `json:"temp_celsius,omitempty"` + TempCelsius *float64 `json:"tempCelsius,omitempty"` Humidity *float64 `json:"humidity,omitempty"` Description *string `json:"description,omitempty"` Icon *string `json:"icon,omitempty"` - WindSpeed *float64 `json:"wind_speed,omitempty"` - RainVolume1h *float64 `json:"rain_volume_1h,omitempty"` - ObservedAt *time.Time `json:"observed_at,omitempty"` - WeatherLastUpdated *time.Time `json:"weather_last_updated,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 { diff --git a/backend/internal/repository/postgres_farm_analytics.go b/backend/internal/repository/postgres_farm_analytics.go index bfd6fd9..72e4ef1 100644 --- a/backend/internal/repository/postgres_farm_analytics.go +++ b/backend/internal/repository/postgres_farm_analytics.go @@ -3,6 +3,8 @@ package repository import ( "context" + "database/sql" + "encoding/json" "errors" "fmt" "log/slog" @@ -10,25 +12,24 @@ import ( "github.com/forfarm/backend/internal/domain" "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" ) -type PostgresFarmAnalyticsRepository struct { - pool *pgxpool.Pool +type postgresFarmAnalyticsRepository struct { + conn Connection logger *slog.Logger } -func NewPostgresFarmAnalyticsRepository(pool *pgxpool.Pool, logger *slog.Logger) domain.AnalyticsRepository { +func NewPostgresFarmAnalyticsRepository(conn Connection, logger *slog.Logger) domain.AnalyticsRepository { if logger == nil { logger = slog.Default() } - return &PostgresFarmAnalyticsRepository{ - pool: pool, + return &postgresFarmAnalyticsRepository{ + conn: conn, logger: logger, } } -func (r *PostgresFarmAnalyticsRepository) GetFarmAnalytics(ctx context.Context, farmID string) (*domain.FarmAnalytics, error) { +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, @@ -40,194 +41,404 @@ func (r *PostgresFarmAnalyticsRepository) GetFarmAnalytics(ctx context.Context, FROM public.farm_analytics WHERE farm_id = $1` - var fa domain.FarmAnalytics - // Pointers for nullable database columns - var weatherTemp, weatherHumid, weatherWind, weatherRain *float64 - var weatherDesc, weatherIcon, overallStatus *string - var weatherObservedAt, weatherLastUpdated, invLastUpdated, cropLastUpdated *time.Time + 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.pool.QueryRow(ctx, query, farmID).Scan( - &fa.FarmID, &fa.FarmName, &fa.OwnerID, &fa.FarmType, &fa.TotalSize, &fa.Latitude, &fa.Longitude, - &weatherTemp, &weatherHumid, &weatherDesc, &weatherIcon, - &weatherWind, &weatherRain, &weatherObservedAt, &weatherLastUpdated, - &fa.InventoryInfo.TotalItems, &fa.InventoryInfo.LowStockCount, &invLastUpdated, - &fa.CropInfo.TotalCount, &fa.CropInfo.GrowingCount, &cropLastUpdated, - &overallStatus, &fa.AnalyticsLastUpdated, + 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, pgx.ErrNoRows) { - return nil, domain.ErrNotFound // Use domain error + 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("Error fetching farm analytics", "farm_id", farmID, "error", err) - return nil, fmt.Errorf("database error fetching analytics for farm %s: %w", farmID, err) + 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) } - fa.Weather = &domain.WeatherData{ - TempCelsius: weatherTemp, - Humidity: weatherHumid, - Description: weatherDesc, - Icon: weatherIcon, - WindSpeed: weatherWind, - RainVolume1h: weatherRain, - ObservedAt: weatherObservedAt, - WeatherLastUpdated: weatherLastUpdated, + // Handle nullable fields + if farmType.Valid { + analytics.FarmType = &farmType.String + } + if totalSize.Valid { + analytics.TotalSize = &totalSize.String + } + if overallStatus.Valid { + analytics.OverallStatus = &overallStatus.String } - fa.InventoryInfo.LastUpdated = invLastUpdated - fa.CropInfo.LastUpdated = cropLastUpdated - fa.OverallStatus = overallStatus - return &fa, nil + // 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 } -func (r *PostgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error { +// --- Calculation Helper --- + +// calculateGrowthProgress calculates the percentage completion based on planting date and maturity days. +func calculateGrowthProgress(plantedAt time.Time, daysToMaturity *int) int { + if daysToMaturity == nil || *daysToMaturity <= 0 { + return 0 // Cannot calculate if maturity days are unknown or zero + } + if plantedAt.IsZero() { + return 0 // Cannot calculate if planting date is unknown + } + + today := time.Now() + daysElapsed := today.Sub(plantedAt).Hours() / 24 + progress := (daysElapsed / float64(*daysToMaturity)) * 100 + + // Clamp progress between 0 and 100 + if progress < 0 { + return 0 + } + if progress > 100 { + return 100 + } + return int(progress) +} + +// --- GetCropAnalytics --- + +// GetCropAnalytics retrieves and calculates analytics data for a specific crop. +func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context, cropID string) (*domain.CropAnalytics, error) { query := ` - INSERT INTO public.farm_analytics ( - farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, analytics_last_updated - ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) - 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, - latitude = EXCLUDED.latitude, - longitude = EXCLUDED.longitude, - analytics_last_updated = NOW()` - - _, err := r.pool.Exec(ctx, query, - farm.UUID, farm.Name, farm.OwnerID, farm.FarmType, farm.TotalSize, farm.Lat, farm.Lon, - ) - if err != nil { - r.logger.Error("Error upserting farm base analytics", "farm_id", farm.UUID, "error", err) - return fmt.Errorf("failed to save base farm analytics for %s: %w", farm.UUID, err) - } - r.logger.Debug("Upserted farm base analytics", "farm_id", farm.UUID) - return nil -} - -func (r *PostgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error { - if weatherData == nil { - return errors.New("weather data cannot be nil for update") - } - query := ` - UPDATE public.farm_analytics SET - weather_temp_celsius = $2, - weather_humidity = $3, - weather_description = $4, - weather_icon = $5, - weather_wind_speed = $6, - weather_rain_1h = $7, - weather_observed_at = $8, - weather_last_updated = NOW(), -- Use current time for the update time - analytics_last_updated = NOW() - WHERE farm_id = $1` - - _, err := r.pool.Exec(ctx, query, - farmID, - weatherData.TempCelsius, - weatherData.Humidity, - weatherData.Description, - weatherData.Icon, - weatherData.WindSpeed, - weatherData.RainVolume1h, - weatherData.ObservedAt, - ) - if err != nil { - r.logger.Error("Error updating farm weather analytics", "farm_id", farmID, "error", err) - return fmt.Errorf("failed to update weather analytics for farm %s: %w", farmID, err) - } - r.logger.Debug("Updated farm weather analytics", "farm_id", farmID) - return nil -} - -func (r *PostgresFarmAnalyticsRepository) UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error { - countQuery := ` SELECT - COUNT(*), - COUNT(*) FILTER (WHERE lower(status) = 'growing') - FROM public.croplands - WHERE farm_id = $1 + c.uuid, c.name, c.farm_id, c.status, c.growth_stage, c.land_size, c.updated_at, + p.name, p.variety, p.days_to_maturity, + c.created_at -- Needed for growth calculation + FROM + croplands c + JOIN + plants p ON c.plant_id = p.uuid + WHERE + c.uuid = $1 ` - var totalCount, growingCount int - err := r.pool.QueryRow(ctx, countQuery, farmID).Scan(&totalCount, &growingCount) + + var analytics domain.CropAnalytics + var plantName string + var variety sql.NullString + var daysToMaturity sql.NullInt32 + var plantedAt time.Time // Use cropland created_at as planted date proxy + + // Use r.conn here + err := r.conn.QueryRow(ctx, query, cropID).Scan( + &analytics.CropID, + &analytics.CropName, + &analytics.FarmID, + &analytics.CurrentStatus, + &analytics.GrowthStage, + &analytics.LandSize, + &analytics.LastUpdated, + &plantName, + &variety, + &daysToMaturity, + &plantedAt, + ) + if err != nil { - if !errors.Is(err, pgx.ErrNoRows) { - r.logger.Error("Error calculating crop counts", "farm_id", farmID, "error", err) - return fmt.Errorf("failed to calculate crop stats for farm %s: %w", farmID, err) + if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { // Check pgx error too + r.logger.Warn("Crop analytics query returned no rows", "crop_id", cropID) + return nil, domain.ErrNotFound } + r.logger.Error("Failed to query crop analytics data", "crop_id", cropID, "error", err) + return nil, fmt.Errorf("database query failed for crop analytics: %w", err) } - updateQuery := ` - UPDATE public.farm_analytics SET - crop_total_count = $2, - crop_growing_count = $3, - crop_last_updated = NOW(), - analytics_last_updated = NOW() - WHERE farm_id = $1` + analytics.PlantName = plantName + if variety.Valid { + analytics.Variety = &variety.String + } - cmdTag, err := r.pool.Exec(ctx, updateQuery, farmID, totalCount, growingCount) + // Calculate Growth Progress + var maturityDaysPtr *int + if daysToMaturity.Valid { + maturityInt := int(daysToMaturity.Int32) + maturityDaysPtr = &maturityInt + } + analytics.GrowthProgress = calculateGrowthProgress(plantedAt, maturityDaysPtr) + + // Fetch Farm Analytics to get weather + farmAnalytics, err := r.GetFarmAnalytics(ctx, analytics.FarmID) // Call using r receiver + if err == nil && farmAnalytics != nil && farmAnalytics.Weather != nil { + analytics.Temperature = farmAnalytics.Weather.TempCelsius + analytics.Humidity = farmAnalytics.Weather.Humidity + windSpeedStr := fmt.Sprintf("%.2f", *farmAnalytics.Weather.WindSpeed) + analytics.WindSpeed = &windSpeedStr + if farmAnalytics.Weather.RainVolume1h != nil { + rainStr := fmt.Sprintf("%.2f", *farmAnalytics.Weather.RainVolume1h) + analytics.Rainfall = &rainStr + } + analytics.Sunlight = nil // Placeholder - requires source + analytics.SoilMoisture = nil // Placeholder - requires source + } else if err != nil && !errors.Is(err, domain.ErrNotFound) { + r.logger.Warn("Could not fetch farm analytics for weather data", "farm_id", analytics.FarmID, "error", err) + } + + // Placeholder for other fields + analytics.PlantHealth = new(string) // Initialize pointer + *analytics.PlantHealth = "good" // Default/placeholder value + analytics.NextAction = nil + analytics.NextActionDue = nil + analytics.NutrientLevels = nil // Needs dedicated data source + + r.logger.Debug("Successfully retrieved crop analytics", "crop_id", cropID, "farm_id", analytics.FarmID) + return &analytics, nil +} + +// --- Implement other AnalyticsRepository methods --- + +func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error { + query := ` + INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, lat, lon, last_updated) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (farm_id) DO UPDATE + SET farm_name = EXCLUDED.farm_name, + owner_id = EXCLUDED.owner_id, + farm_type = EXCLUDED.farm_type, + total_size = EXCLUDED.total_size, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, + last_updated = EXCLUDED.last_updated;` + + _, err := r.conn.Exec(ctx, query, + farm.UUID, + farm.Name, + farm.OwnerID, + farm.FarmType, // Handle potential empty string vs null if needed + farm.TotalSize, // Handle potential empty string vs null if needed + farm.Lat, + farm.Lon, + time.Now().UTC(), // Update timestamp on change + ) if err != nil { - r.logger.Error("Error updating farm crop stats", "farm_id", farmID, "error", err) - return fmt.Errorf("failed to update crop stats for farm %s: %w", farmID, err) + 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 if it should always exist - // return r.CreateOrUpdateFarmBaseData(ctx, &domain.Farm{UUID: farmID /* Fetch other details */}) + // Optionally, create the base record here + } else { + r.logger.Debug("Updated farm analytics crop stats", "farm_id", farmID, "total", totalCount, "growing", growingCount) } - - r.logger.Debug("Updated farm crop stats", "farm_id", farmID, "total", totalCount, "growing", growingCount) return nil } -// TODO: Implement actual count calculation if needed later. -func (r *PostgresFarmAnalyticsRepository) UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error { - query := ` - UPDATE public.farm_analytics SET - -- inventory_total_items = (SELECT COUNT(*) FROM ... WHERE farm_id = $1), -- Example future logic - -- inventory_low_stock_count = (SELECT COUNT(*) FROM ... WHERE farm_id = $1 AND status = 'low'), -- Example - inventory_last_updated = NOW(), - analytics_last_updated = NOW() - WHERE farm_id = $1` +// UpdateFarmAnalyticsInventoryStats needs to query inventory_items +func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error { + var totalItems, lowStockCount int + var lastUpdated sql.NullTime - cmdTag, err := r.pool.Exec(ctx, query, farmID) + // 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 { - r.logger.Error("Error touching inventory timestamp in farm analytics", "farm_id", farmID, "error", err) - return fmt.Errorf("failed to update inventory stats timestamp for farm %s: %w", farmID, err) + 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 timestamp", "farm_id", farmID) + 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) } - - r.logger.Debug("Updated farm inventory timestamp", "farm_id", farmID) return nil } -func (r *PostgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Context, farmID string) error { - query := `DELETE FROM public.farm_analytics WHERE farm_id = $1` - _, err := r.pool.Exec(ctx, query, farmID) +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("Error deleting farm analytics", "farm_id", farmID, "error", err) - return fmt.Errorf("failed to delete analytics for farm %s: %w", farmID, err) + 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) } - r.logger.Debug("Deleted farm analytics", "farm_id", farmID) + 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 { +func (r *postgresFarmAnalyticsRepository) UpdateFarmOverallStatus(ctx context.Context, farmID string, status string) error { query := ` - UPDATE public.farm_analytics SET - overall_status = $2, - analytics_last_updated = NOW() - WHERE farm_id = $1` + UPDATE farm_analytics + SET overall_status = $1, + last_updated = $2 + WHERE farm_id = $3;` - cmdTag, err := r.pool.Exec(ctx, query, farmID, status) + cmdTag, err := r.conn.Exec(ctx, query, status, time.Now().UTC(), farmID) if err != nil { - r.logger.Error("Error updating farm overall status", "farm_id", farmID, "status", status, "error", err) - return fmt.Errorf("failed to update overall status for farm %s: %w", farmID, err) + 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/frontend/api/crop.ts b/frontend/api/crop.ts index ea2c41a..a8eafe8 100644 --- a/frontend/api/crop.ts +++ b/frontend/api/crop.ts @@ -1,33 +1,54 @@ import axiosInstance from "./config"; -import type { Cropland } from "@/types"; +// Use refactored types +import type { Cropland, CropAnalytics } from "@/types"; export interface CropResponse { croplands: Cropland[]; } /** - * Fetch a specific Crop by FarmID. - * Calls GET /crop/farm/{farm_id} and returns fallback data on failure. + * Fetch all Croplands for a specific FarmID. Returns CropResponse. */ -export async function getCrop(farmId: string): Promise { +export async function getCropsByFarmId(farmId: string): Promise { + // Assuming backend returns { "croplands": [...] } return axiosInstance.get(`/crop/farm/${farmId}`).then((res) => res.data); } -// body -// { -// "farm_id": "string", -// "growth_stage": "string", -// "land_size": 0, -// "name": "string", -// "plant_id": "string", -// "priority": 0, -// "status": "string", -// } +/** + * 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 by FarmID. - * Calls POST /crop and returns fallback data on failure. + * Create a new crop (Cropland). Sends camelCase data matching backend tags. Returns Cropland. */ -export async function createCrop(data: Partial): Promise { - return axiosInstance.post(`/crop`, data).then((res) => res.data); +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 d56174b..0071ddb 100644 --- a/frontend/api/farm.ts +++ b/frontend/api/farm.ts @@ -1,80 +1,63 @@ import axiosInstance from "./config"; +// Use the refactored Farm type import type { Farm } from "@/types"; /** - * Fetch an array of farms. - * Calls GET /farms and returns fallback dummy data on failure. + * Fetch an array of farms. Returns Farm[]. */ export async function fetchFarms(): Promise { - return axiosInstance.get("/farms").then((res) => res.data); + // 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); } /** - * Create a new farm. - * Calls POST /farms with a payload that uses snake_case keys. + * Create a new farm. Sends camelCase data. Returns Farm. */ -export async function createFarm(data: Partial): Promise { - return axiosInstance.post("/farms", data).then((res) => res.data); +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); } /** - * Fetch a specific farm by ID. - * Calls GET /farms/{farm_id} and returns fallback data on failure. + * Fetch a specific farm by ID. Returns Farm. */ export async function getFarm(farmId: string): Promise { - return axiosInstance.get(`/farms/${farmId}`).then((res) => res.data); + 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. - * Calls PUT /farms/{farm_id} with a snake_case payload. + * Update an existing farm. Sends camelCase data. Returns Farm. */ export async function updateFarm( farmId: string, - data: { - farm_type: string; - lat: number; - lon: number; - name: string; - total_size: string; - } + data: Partial> ): Promise { - // Simulate a network delay. - await new Promise((resolve) => setTimeout(resolve, 800)); - - try { - const response = await axiosInstance.put(`/farms/${farmId}`, data); - return response.data; - } catch (error: any) { - console.error(`Error updating farm ${farmId}. Returning fallback data:`, error); - const now = new Date().toISOString(); - return { - CreatedAt: now, - FarmType: data.farm_type, - Lat: data.lat, - Lon: data.lon, - Name: data.name, - OwnerID: "updated_owner", - TotalSize: data.total_size, - UUID: farmId, - UpdatedAt: now, - }; - } + // 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. - * Calls DELETE /farms/{farm_id} and returns a success message. + * Delete a specific farm. Returns { message: string }. */ export async function deleteFarm(farmId: string): Promise<{ message: string }> { - // Simulate a network delay. - await new Promise((resolve) => setTimeout(resolve, 500)); - - try { - await axiosInstance.delete(`/farms/${farmId}`); - return { message: "Farm deleted successfully" }; - } catch (error: any) { - console.error(`Error deleting farm ${farmId}. Assuming deletion was successful:`, error); - return { message: "Farm deleted successfully (dummy)" }; - } + return axiosInstance.delete(`/farms/${farmId}`).then((res) => res.data); }