mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04:08 +01:00
refactor: use camelCase for frontend api fetch
This commit is contained in:
parent
009cfb10ff
commit
9691b845d9
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<CropResponse> {
|
||||
export async function getCropsByFarmId(farmId: string): Promise<CropResponse> {
|
||||
// Assuming backend returns { "croplands": [...] }
|
||||
return axiosInstance.get<CropResponse>(`/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<Cropland> {
|
||||
// Assuming backend returns { "cropland": ... }
|
||||
return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
|
||||
// If backend returns object directly: return axiosInstance.get<Cropland>(`/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<Cropland>): Promise<Cropland> {
|
||||
return axiosInstance.post<Cropland>(`/crop`, data).then((res) => res.data);
|
||||
export async function createCrop(data: Partial<Omit<Cropland, "uuid" | "createdAt" | "updatedAt">>): Promise<Cropland> {
|
||||
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<Cropland>(`/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<CropAnalytics> {
|
||||
// Assuming backend returns { body: { ... } } structure from Huma
|
||||
return axiosInstance.get<CropAnalytics>(`/analytics/crop/${cropId}`).then((res) => res.data);
|
||||
}
|
||||
|
||||
@ -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<Farm[]> {
|
||||
return axiosInstance.get<Farm[]>("/farms").then((res) => res.data);
|
||||
// Backend already returns camelCase due to updated JSON tags
|
||||
return axiosInstance.get<Farm[]>("/farms").then((res) => res.data); // Assuming backend wraps in { "farms": [...] }
|
||||
// If backend returns array directly: return axiosInstance.get<Farm[]>("/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<Farm>): Promise<Farm> {
|
||||
return axiosInstance.post<Farm>("/farms", data).then((res) => res.data);
|
||||
export async function createFarm(
|
||||
data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>
|
||||
): Promise<Farm> {
|
||||
// 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<Farm>("/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<Farm> {
|
||||
return axiosInstance.get<Farm>(`/farms/${farmId}`).then((res) => res.data);
|
||||
return axiosInstance.get<Farm>(`/farms/${farmId}`).then((res) => res.data); // Assuming backend wraps in { "farm": ... }
|
||||
// If backend returns object directly: return axiosInstance.get<Farm>(`/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<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>
|
||||
): Promise<Farm> {
|
||||
// Simulate a network delay.
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
|
||||
try {
|
||||
const response = await axiosInstance.put<Farm>(`/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<Farm>(`/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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user