refactor: use camelCase for frontend api fetch

This commit is contained in:
Sosokker 2025-04-03 10:32:25 +07:00
parent 009cfb10ff
commit 9691b845d9
17 changed files with 992 additions and 442 deletions

View File

@ -18,22 +18,43 @@ func (a *api) registerAnalyticsRoutes(_ chi.Router, api huma.API) {
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getFarmAnalytics", OperationID: "getFarmAnalytics",
Method: http.MethodGet, Method: http.MethodGet,
Path: prefix + "/farm/{farm_id}", Path: prefix + "/farm/{farmId}", // Changed path param name
Tags: tags, Tags: tags,
Summary: "Get aggregated analytics data for a specific farm", Summary: "Get aggregated analytics data for a specific farm",
Description: "Retrieves various analytics metrics for a farm, requiring user ownership.", Description: "Retrieves various analytics metrics for a farm, requiring user ownership.",
}, a.getFarmAnalyticsHandler) }, 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 { type GetFarmAnalyticsInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` 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 { type GetFarmAnalyticsOutput struct {
Body domain.FarmAnalytics `json:"body"` 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) { func (a *api) getFarmAnalyticsHandler(ctx context.Context, input *GetFarmAnalyticsInput) (*GetFarmAnalyticsOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header) userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { 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.") return nil, huma.Error500InternalServerError("Failed to retrieve analytics data.")
} }
// Authorization Check: User must own the farm
if analyticsData.OwnerID != userID { 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) 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.") 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 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
}

View File

@ -55,6 +55,7 @@ type RegisterInput struct {
type RegisterOutput struct { type RegisterOutput struct {
Body struct { Body struct {
// Use camelCase for JSON tags
Token string `json:"token" example:"JWT token for the user"` 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 { 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 { 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) _, err := a.userRepo.GetByEmail(ctx, input.Body.Email)
if err == domain.ErrNotFound { // Check if the error is specifically ErrNotFound
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Body.Password), bcrypt.DefaultCost) if errors.Is(err, domain.ErrNotFound) {
if err != nil { hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(input.Body.Password), bcrypt.DefaultCost)
return nil, err 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, Email: input.Body.Email,
Password: string(hashedPassword), Password: string(hashedPassword),
}) IsActive: true,
if err != nil {
return nil, err
} }
user, err := a.userRepo.GetByEmail(ctx, input.Body.Email) createErr := a.userRepo.CreateOrUpdate(ctx, newUser)
if err != nil { if createErr != nil {
return nil, err 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) token, tokenErr := utilities.CreateJwtToken(newUser.UUID)
if err != nil { if tokenErr != nil {
return nil, err 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 resp.Body.Token = token
return resp, nil 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) { func (a *api) loginHandler(ctx context.Context, input *LoginInput) (*LoginOutput, error) {
resp := &LoginOutput{} resp := &LoginOutput{}
if input == nil { if input == nil {
return nil, errors.New("invalid input") return nil, huma.Error400BadRequest("Invalid input: missing request body")
} }
if input.Body.Email == "" { if input.Body.Email == "" {
return nil, errors.New("email field is required") return nil, huma.Error400BadRequest("Email field is required")
} }
if input.Body.Password == "" { 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 { 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) user, err := a.userRepo.GetByEmail(ctx, input.Body.Email)
if err != nil { 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 { 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) token, err := utilities.CreateJwtToken(user.UUID)
if err != nil { 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 resp.Body.Token = token

View File

@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@ -37,7 +38,7 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getAllCroplandsByFarmID", OperationID: "getAllCroplandsByFarmID",
Method: http.MethodGet, Method: http.MethodGet,
Path: prefix + "/farm/{farm_id}", Path: prefix + "/farm/{farmId}",
Tags: tags, Tags: tags,
}, a.getAllCroplandsByFarmIDHandler) }, a.getAllCroplandsByFarmIDHandler)
@ -53,41 +54,54 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
type GetCroplandsOutput struct { type GetCroplandsOutput struct {
Body struct { Body struct {
Croplands []domain.Cropland `json:"croplands"` Croplands []domain.Cropland `json:"croplands"`
} `json:"body"` }
} }
type GetCroplandByIDOutput struct { type GetCroplandByIDOutput struct {
Body struct { Body struct {
Cropland domain.Cropland `json:"cropland"` Cropland domain.Cropland `json:"cropland"`
} `json:"body"` }
} }
type CreateOrUpdateCroplandInput struct { type CreateOrUpdateCroplandInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
Body struct { Body struct {
UUID string `json:"UUID,omitempty"` UUID string `json:"uuid,omitempty"`
Name string `json:"Name"` Name string `json:"name"`
Status string `json:"Status"` Status string `json:"status"`
Priority int `json:"Priority"` Priority int `json:"priority"`
LandSize float64 `json:"LandSize"` LandSize float64 `json:"landSize"`
GrowthStage string `json:"GrowthStage"` GrowthStage string `json:"growthStage"`
PlantID string `json:"PlantID"` PlantID string `json:"plantId"`
FarmID string `json:"FarmID"` 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}}"` GeoFeature json.RawMessage `json:"geoFeature,omitempty"`
} `json:"body"` }
} }
type CreateOrUpdateCroplandOutput struct { type CreateOrUpdateCroplandOutput struct {
Body struct { Body struct {
Cropland domain.Cropland `json:"cropland"` 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{} resp := &GetCroplandsOutput{}
croplands, err := a.cropRepo.GetAll(ctx) croplands, err := a.cropRepo.GetAll(ctx)
if err != nil { 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 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 { func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"`
}) (*GetCroplandByIDOutput, error) { }) (*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{} resp := &GetCroplandByIDOutput{}
if input.UUID == "" { if input.UUID == "" {
return nil, huma.Error400BadRequest("UUID parameter is required") return nil, huma.Error400BadRequest("UUID parameter is required")
} }
_, err := uuid.FromString(input.UUID) _, err = uuid.FromString(input.UUID)
if err != nil { 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) cropland, err := a.cropRepo.GetByID(ctx, input.UUID)
if err != nil { if err != nil {
if errors.Is(err, domain.ErrNotFound) { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
return nil, huma.Error404NotFound("cropland not found") 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 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 { 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) { }) (*GetCroplandsOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err)
}
resp := &GetCroplandsOutput{} resp := &GetCroplandsOutput{}
if input.FarmID == "" { 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 { 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) croplands, err := a.cropRepo.GetByFarmID(ctx, input.FarmID)
if err != nil { 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 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) { 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{} resp := &CreateOrUpdateCroplandOutput{}
// --- Input Validation ---
if input.Body.Name == "" { if input.Body.Name == "" {
return nil, huma.Error400BadRequest("name is required") 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") return nil, huma.Error400BadRequest("status is required")
} }
if input.Body.GrowthStage == "" { if input.Body.GrowthStage == "" {
return nil, huma.Error400BadRequest("growth_stage is required") return nil, huma.Error400BadRequest("growthStage is required")
} }
if input.Body.PlantID == "" { if input.Body.PlantID == "" {
return nil, huma.Error400BadRequest("plant_id is required") return nil, huma.Error400BadRequest("plantId is required")
} }
if input.Body.FarmID == "" { 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 input.Body.UUID != "" {
if _, err := uuid.FromString(input.Body.UUID); err != nil { if _, err := uuid.FromString(input.Body.UUID); err != nil {
return nil, huma.Error400BadRequest("invalid cropland UUID format") return nil, huma.Error400BadRequest("invalid cropland UUID format")
} }
} }
if _, err := uuid.FromString(input.Body.PlantID); err != nil { 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") 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) { 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{ cropland := &domain.Cropland{
UUID: input.Body.UUID, UUID: input.Body.UUID,
Name: input.Body.Name, Name: input.Body.Name,
@ -190,11 +295,15 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr
GeoFeature: input.Body.GeoFeature, 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 { 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 resp.Body.Cropland = *cropland
return resp, nil return resp, nil
} }

View File

@ -2,6 +2,8 @@ package api
import ( import (
"context" "context"
"database/sql"
"errors"
"fmt" "fmt"
"net/http" "net/http"
@ -25,7 +27,7 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) {
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getFarmByID", OperationID: "getFarmByID",
Method: http.MethodGet, Method: http.MethodGet,
Path: prefix + "/{farm_id}", Path: prefix + "/{farmId}",
Tags: tags, Tags: tags,
}, a.getFarmByIDHandler) }, a.getFarmByIDHandler)
@ -39,14 +41,14 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) {
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "updateFarm", OperationID: "updateFarm",
Method: http.MethodPut, Method: http.MethodPut,
Path: prefix + "/{farm_id}", Path: prefix + "/{farmId}",
Tags: tags, Tags: tags,
}, a.updateFarmHandler) }, a.updateFarmHandler)
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "deleteFarm", OperationID: "deleteFarm",
Method: http.MethodDelete, Method: http.MethodDelete,
Path: prefix + "/{farm_id}", Path: prefix + "/{farmId}",
Tags: tags, Tags: tags,
}, a.deleteFarmHandler) }, a.deleteFarmHandler)
} }
@ -55,15 +57,14 @@ func (a *api) registerFarmRoutes(_ chi.Router, api huma.API) {
// Input and Output types // Input and Output types
// //
// CreateFarmInput contains the request data for creating a new farm.
type CreateFarmInput struct { type CreateFarmInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
Body struct { Body struct {
Name string `json:"Name"` Name string `json:"name" required:"true"`
Lat float64 `json:"Lat"` Lat float64 `json:"lat" required:"true"`
Lon float64 `json:"Lon"` Lon float64 `json:"lon" required:"true"`
FarmType string `json:"FarmType,omitempty"` FarmType string `json:"farmType,omitempty"`
TotalSize string `json:"TotalSize,omitempty"` TotalSize string `json:"totalSize,omitempty"`
} }
} }
@ -78,38 +79,37 @@ type GetAllFarmsInput struct {
} }
type GetAllFarmsOutput struct { type GetAllFarmsOutput struct {
Body []domain.Farm Body []domain.Farm `json:"farms"`
} }
type GetFarmByIDInput struct { type GetFarmByIDInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
FarmID string `path:"farm_id"` FarmID string `path:"farmId" required:"true"`
} }
type GetFarmByIDOutput struct { type GetFarmByIDOutput struct {
Body domain.Farm Body domain.Farm `json:"farm"`
} }
// UpdateFarmInput uses pointer types for optional/nullable fields.
type UpdateFarmInput struct { type UpdateFarmInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
FarmID string `path:"farm_id"` FarmID string `path:"farmId" required:"true"`
Body struct { Body struct {
Name string `json:"Name,omitempty"` Name *string `json:"name,omitempty"`
Lat *float64 `json:"Lat,omitempty"` Lat *float64 `json:"lat,omitempty"`
Lon *float64 `json:"Lon,omitempty"` Lon *float64 `json:"lon,omitempty"`
FarmType *string `json:"FarmType,omitempty"` FarmType *string `json:"farmType,omitempty"`
TotalSize *string `json:"TotalSize,omitempty"` TotalSize *string `json:"totalSize,omitempty"`
} }
} }
type UpdateFarmOutput struct { type UpdateFarmOutput struct {
Body domain.Farm Body domain.Farm `json:"farm"`
} }
type DeleteFarmInput struct { type DeleteFarmInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
FarmID string `path:"farm_id"` FarmID string `path:"farmId" required:"true"`
} }
type DeleteFarmOutput struct { type DeleteFarmOutput struct {
@ -125,7 +125,7 @@ type DeleteFarmOutput struct {
func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*CreateFarmOutput, error) { func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*CreateFarmOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header) userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, err return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
farm := &domain.Farm{ farm := &domain.Farm{
@ -136,11 +136,21 @@ func (a *api) createFarmHandler(ctx context.Context, input *CreateFarmInput) (*C
TotalSize: input.Body.TotalSize, TotalSize: input.Body.TotalSize,
OwnerID: userID, 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 { 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{ return &CreateFarmOutput{
Body: struct { Body: struct {
UUID string `json:"uuid"` 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) { func (a *api) getAllFarmsHandler(ctx context.Context, input *GetAllFarmsInput) (*GetAllFarmsOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header) userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, err return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
farms, err := a.farmRepo.GetByOwnerID(ctx, userID) farms, err := a.farmRepo.GetByOwnerID(ctx, userID)
if err != nil { 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 return &GetAllFarmsOutput{Body: farms}, nil
} }
func (a *api) getFarmByIDHandler(ctx context.Context, input *GetFarmByIDInput) (*GetFarmByIDOutput, error) { func (a *api) getFarmByIDHandler(ctx context.Context, input *GetFarmByIDInput) (*GetFarmByIDOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header) userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, err return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
farm, err := a.farmRepo.GetByID(ctx, input.FarmID) farm, err := a.farmRepo.GetByID(ctx, input.FarmID)
if err != nil { 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 { 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 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) { func (a *api) updateFarmHandler(ctx context.Context, input *UpdateFarmInput) (*UpdateFarmOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header) userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, err return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
farm, err := a.farmRepo.GetByID(ctx, input.FarmID) farm, err := a.farmRepo.GetByID(ctx, input.FarmID)
if err != nil { 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 { 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 != "" { // Apply updates selectively
farm.Name = input.Body.Name 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 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 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 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 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 { 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")
} }
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: *farm}, nil
}
return &UpdateFarmOutput{Body: *updatedFarm}, nil
} }
func (a *api) deleteFarmHandler(ctx context.Context, input *DeleteFarmInput) (*DeleteFarmOutput, error) { func (a *api) deleteFarmHandler(ctx context.Context, input *DeleteFarmInput) (*DeleteFarmOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header) userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, err return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
farm, err := a.farmRepo.GetByID(ctx, input.FarmID) farm, err := a.farmRepo.GetByID(ctx, input.FarmID)
if err != nil { 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 { 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 { 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{ return &DeleteFarmOutput{
Body: struct { Body: struct {
Message string `json:"message"` Message string `json:"message"`

View File

@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"errors" "errors"
"net/http" "net/http"
@ -25,7 +26,7 @@ func (a *api) registerOauthRoutes(_ chi.Router, apiInstance huma.API) {
type ExchangeTokenInput struct { type ExchangeTokenInput struct {
Body 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 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) { func (a *api) exchangeHandler(ctx context.Context, input *ExchangeTokenInput) (*ExchangeTokenOutput, error) {
if input.Body.AccessToken == "" { 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) googleUserID, email, err := utilities.ExtractGoogleUserID(input.Body.AccessToken)
if err != nil { 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) user, err := a.userRepo.GetByEmail(ctx, email)
if err == domain.ErrNotFound { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
newPassword, err := generateRandomPassword(12) a.logger.Info("Creating new user from Google OAuth", "email", email, "googleUserId", googleUserID)
if err != nil {
return nil, err 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) hashedPassword, hashErr := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil { if hashErr != nil {
return nil, err 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{ newUser := &domain.User{
Email: email, 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 { if createErr := a.userRepo.CreateOrUpdate(ctx, newUser); createErr != nil {
return nil, err 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 user = *newUser
} else if err != nil { } 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) token, err := utilities.CreateJwtToken(user.UUID)
if err != nil { 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 := &ExchangeTokenOutput{}
output.Body.JWT = token output.Body.JWT = token
output.Body.Email = email output.Body.Email = email // Return the email for frontend context
_ = googleUserID // Maybe need in the future _ = 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 return output, nil
} }

View File

@ -31,7 +31,12 @@ func (a *api) getAllPlantHandler(ctx context.Context, input *struct{}) (*GetAllP
resp := &GetAllPlantsOutput{} resp := &GetAllPlantsOutput{}
plants, err := a.plantRepo.GetAll(ctx) plants, err := a.plantRepo.GetAll(ctx)
if err != nil { 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 resp.Body.Plants = plants

View File

@ -2,6 +2,7 @@ package api
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -28,6 +29,7 @@ type getSelfDataInput struct {
Authorization string `header:"Authorization" required:"true" example:"Bearer token"` Authorization string `header:"Authorization" required:"true" example:"Bearer token"`
} }
// getSelfDataOutput uses domain.User which now has camelCase tags
type getSelfDataOutput struct { type getSelfDataOutput struct {
Body struct { Body struct {
User domain.User `json:"user"` User domain.User `json:"user"`
@ -39,23 +41,32 @@ func (a *api) getSelfData(ctx context.Context, input *getSelfDataInput) (*getSel
authHeader := input.Authorization authHeader := input.Authorization
if authHeader == "" { if authHeader == "" {
return nil, fmt.Errorf("no authorization header provided") return nil, huma.Error401Unauthorized("No authorization header provided")
} }
authToken := strings.TrimPrefix(authHeader, "Bearer ") authToken := strings.TrimPrefix(authHeader, "Bearer ")
if authToken == "" { if authToken == "" {
return nil, fmt.Errorf("no token provided") return nil, huma.Error401Unauthorized("No token provided in Authorization header")
} }
uuid, err := utilities.ExtractUUIDFromToken(authToken) uuid, err := utilities.ExtractUUIDFromToken(authToken)
if err != nil { 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) user, err := a.userRepo.GetByUUID(ctx, uuid)
if err != nil { 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 resp.Body.User = user
return resp, nil return resp, nil

View File

@ -6,42 +6,58 @@ import (
) )
type FarmAnalytics struct { type FarmAnalytics struct {
FarmID string `json:"farm_id"` FarmID string `json:"farmId"`
FarmName string `json:"farm_name"` FarmName string `json:"farmName"`
OwnerID string `json:"owner_id"` OwnerID string `json:"ownerId"`
FarmType *string `json:"farm_type,omitempty"` FarmType *string `json:"farmType,omitempty"`
TotalSize *string `json:"total_size,omitempty"` TotalSize *string `json:"totalSize,omitempty"`
Latitude float64 `json:"latitude"` Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"` Longitude float64 `json:"longitude"`
Weather *WeatherData Weather *WeatherData
InventoryInfo struct { InventoryInfo struct {
TotalItems int `json:"total_items"` TotalItems int `json:"totalItems"`
LowStockCount int `json:"low_stock_count"` LowStockCount int `json:"lowStockCount"`
LastUpdated *time.Time `json:"last_updated,omitempty"` LastUpdated *time.Time `json:"lastUpdated,omitempty"`
} `json:"inventory_info"` } `json:"inventoryInfo"`
CropInfo struct { CropInfo struct {
TotalCount int `json:"total_count"` TotalCount int `json:"totalCount"`
GrowingCount int `json:"growing_count"` GrowingCount int `json:"growingCount"`
LastUpdated *time.Time `json:"last_updated,omitempty"` LastUpdated *time.Time `json:"lastUpdated,omitempty"`
} `json:"crop_info"` } `json:"cropInfo"`
OverallStatus *string `json:"overall_status,omitempty"` OverallStatus *string `json:"overallStatus,omitempty"`
AnalyticsLastUpdated time.Time `json:"analytics_last_updated"` AnalyticsLastUpdated time.Time `json:"analyticsLastUpdated"`
} }
type CropAnalytics struct { type CropAnalytics struct {
CropID string `json:"crop_id"` CropID string `json:"cropId"`
CropName string `json:"crop_name"` CropName string `json:"cropName"`
FarmID string `json:"farm_id"` FarmID string `json:"farmId"`
PlantName string `json:"plant_name"` PlantName string `json:"plantName"`
Variety *string `json:"variety,omitempty"` Variety *string `json:"variety,omitempty"`
CurrentStatus string `json:"current_status"` CurrentStatus string `json:"currentStatus"`
GrowthStage string `json:"growth_stage"` GrowthStage string `json:"growthStage"`
LandSize float64 `json:"land_size"` LandSize float64 `json:"landSize"`
LastUpdated time.Time `json:"last_updated"` 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 { type AnalyticsRepository interface {
GetFarmAnalytics(ctx context.Context, farmID string) (*FarmAnalytics, error) GetFarmAnalytics(ctx context.Context, farmID string) (*FarmAnalytics, error)
GetCropAnalytics(ctx context.Context, cropID string) (*CropAnalytics, error)
CreateOrUpdateFarmBaseData(ctx context.Context, farm *Farm) error CreateOrUpdateFarmBaseData(ctx context.Context, farm *Farm) error
UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *WeatherData) error UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *WeatherData) error
UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error

View File

@ -9,17 +9,17 @@ import (
) )
type Cropland struct { type Cropland struct {
UUID string UUID string `json:"uuid"`
Name string Name string `json:"name"`
Status string Status string `json:"status"`
Priority int Priority int `json:"priority"`
LandSize float64 LandSize float64 `json:"landSize"`
GrowthStage string GrowthStage string `json:"growthStage"`
PlantID string PlantID string `json:"plantId"`
FarmID string FarmID string `json:"farmId"`
GeoFeature json.RawMessage GeoFeature json.RawMessage `json:"geoFeature,omitempty"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updatedAt"`
} }
func (c *Cropland) Validate() error { func (c *Cropland) Validate() error {

View File

@ -10,13 +10,13 @@ import (
type Farm struct { type Farm struct {
UUID string `json:"uuid"` UUID string `json:"uuid"`
Name string `json:"name"` Name string `json:"name"`
Lat float64 `json:"latitude"` Lat float64 `json:"lat"`
Lon float64 `json:"longitude"` Lon float64 `json:"lon"`
FarmType string `json:"farm_type,omitempty"` FarmType string `json:"farmType,omitempty"`
TotalSize string `json:"total_size,omitempty"` TotalSize string `json:"totalSize,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updatedAt"`
OwnerID string `json:"owner_id"` OwnerID string `json:"ownerId"`
Crops []Cropland `json:"crops,omitempty"` Crops []Cropland `json:"crops,omitempty"`
} }

View File

@ -23,19 +23,19 @@ type HarvestUnit struct {
} }
type InventoryItem struct { type InventoryItem struct {
ID string ID string `json:"id"`
UserID string UserID string `json:"userId"`
Name string Name string `json:"name"`
CategoryID int CategoryID int `json:"categoryId"`
Category InventoryCategory Category InventoryCategory `json:"category"`
Quantity float64 Quantity float64 `json:"quantity"`
UnitID int UnitID int `json:"unitId"`
Unit HarvestUnit Unit HarvestUnit `json:"unit"`
DateAdded time.Time DateAdded time.Time `json:"dateAdded"`
StatusID int StatusID int `json:"statusId"`
Status InventoryStatus Status InventoryStatus `json:"status"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updatedAt"`
} }
type InventoryFilter struct { type InventoryFilter struct {

View File

@ -8,28 +8,28 @@ import (
) )
type Plant struct { type Plant struct {
UUID string UUID string `json:"uuid"`
Name string Name string `json:"name"`
Variety *string Variety *string `json:"variety,omitempty"`
RowSpacing *float64 RowSpacing *float64 `json:"rowSpacing,omitempty"`
OptimalTemp *float64 OptimalTemp *float64 `json:"optimalTemp,omitempty"`
PlantingDepth *float64 PlantingDepth *float64 `json:"plantingDepth,omitempty"`
AverageHeight *float64 AverageHeight *float64 `json:"averageHeight,omitempty"`
LightProfileID int LightProfileID int `json:"lightProfileId"`
SoilConditionID int SoilConditionID int `json:"soilConditionId"`
PlantingDetail *string PlantingDetail *string `json:"plantingDetail,omitempty"`
IsPerennial bool IsPerennial bool `json:"isPerennial"`
DaysToEmerge *int DaysToEmerge *int `json:"daysToEmerge,omitempty"`
DaysToFlower *int DaysToFlower *int `json:"daysToFlower,omitempty"`
DaysToMaturity *int DaysToMaturity *int `json:"daysToMaturity,omitempty"`
HarvestWindow *int HarvestWindow *int `json:"harvestWindow,omitempty"`
PHValue *float64 PHValue *float64 `json:"phValue,omitempty"`
EstimateLossRate *float64 EstimateLossRate *float64 `json:"estimateLossRate,omitempty"`
EstimateRevenuePerHU *float64 EstimateRevenuePerHU *float64 `json:"estimateRevenuePerHu,omitempty"`
HarvestUnitID int HarvestUnitID int `json:"harvestUnitId"`
WaterNeeds *float64 WaterNeeds *float64 `json:"waterNeeds,omitempty"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updatedAt"`
} }
func (p *Plant) Validate() error { func (p *Plant) Validate() error {

View File

@ -11,14 +11,14 @@ import (
) )
type User struct { type User struct {
ID int64 ID int64 `json:"id"`
UUID string UUID string `json:"uuid"`
Username string Username string `json:"username,omitempty"`
Password string Password string `json:"-"`
Email string Email string `json:"email"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time UpdatedAt time.Time `json:"updatedAt"`
IsActive bool IsActive bool `json:"isActive"`
} }
func (u *User) NormalizedUsername() string { func (u *User) NormalizedUsername() string {
@ -29,16 +29,27 @@ func (u *User) Validate() error {
return validation.ValidateStruct(u, return validation.ValidateStruct(u,
validation.Field(&u.UUID, validation.Required), validation.Field(&u.UUID, validation.Required),
validation.Field(&u.Username, validation.By(func(value interface{}) error { validation.Field(&u.Username, validation.By(func(value interface{}) error {
// Username is now optional
if value == nil { if value == nil {
return nil return nil
} }
if value == "" { if strVal, ok := value.(string); ok && strVal == "" {
return nil return nil
} }
username, ok := value.(*string) username, ok := value.(*string)
if !ok { 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") 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 { if len(*username) < 3 || len(*username) > 20 {
return errors.New("username length must be between 3 and 20") return errors.New("username length must be between 3 and 20")
} }

View File

@ -6,14 +6,14 @@ import (
) )
type WeatherData struct { type WeatherData struct {
TempCelsius *float64 `json:"temp_celsius,omitempty"` TempCelsius *float64 `json:"tempCelsius,omitempty"`
Humidity *float64 `json:"humidity,omitempty"` Humidity *float64 `json:"humidity,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"` Icon *string `json:"icon,omitempty"`
WindSpeed *float64 `json:"wind_speed,omitempty"` WindSpeed *float64 `json:"windSpeed,omitempty"`
RainVolume1h *float64 `json:"rain_volume_1h,omitempty"` RainVolume1h *float64 `json:"rainVolume1h,omitempty"`
ObservedAt *time.Time `json:"observed_at,omitempty"` ObservedAt *time.Time `json:"observedAt,omitempty"`
WeatherLastUpdated *time.Time `json:"weather_last_updated,omitempty"` WeatherLastUpdated *time.Time `json:"weatherLastUpdated,omitempty"`
} }
type WeatherFetcher interface { type WeatherFetcher interface {

View File

@ -3,6 +3,8 @@ package repository
import ( import (
"context" "context"
"database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@ -10,25 +12,24 @@ import (
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
) )
type PostgresFarmAnalyticsRepository struct { type postgresFarmAnalyticsRepository struct {
pool *pgxpool.Pool conn Connection
logger *slog.Logger 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 { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
return &PostgresFarmAnalyticsRepository{ return &postgresFarmAnalyticsRepository{
pool: pool, conn: conn,
logger: logger, 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 := ` query := `
SELECT SELECT
farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, 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 FROM public.farm_analytics
WHERE farm_id = $1` WHERE farm_id = $1`
var fa domain.FarmAnalytics var analytics domain.FarmAnalytics
// Pointers for nullable database columns var farmType sql.NullString
var weatherTemp, weatherHumid, weatherWind, weatherRain *float64 var totalSize sql.NullString
var weatherDesc, weatherIcon, overallStatus *string var weatherJSON, inventoryJSON, cropJSON []byte // Use []byte for JSONB
var weatherObservedAt, weatherLastUpdated, invLastUpdated, cropLastUpdated *time.Time var overallStatus sql.NullString
err := r.pool.QueryRow(ctx, query, farmID).Scan( err := r.conn.QueryRow(ctx, query, farmID).Scan(
&fa.FarmID, &fa.FarmName, &fa.OwnerID, &fa.FarmType, &fa.TotalSize, &fa.Latitude, &fa.Longitude, &analytics.FarmID,
&weatherTemp, &weatherHumid, &weatherDesc, &weatherIcon, &analytics.FarmName,
&weatherWind, &weatherRain, &weatherObservedAt, &weatherLastUpdated, &analytics.OwnerID,
&fa.InventoryInfo.TotalItems, &fa.InventoryInfo.LowStockCount, &invLastUpdated, &farmType,
&fa.CropInfo.TotalCount, &fa.CropInfo.GrowingCount, &cropLastUpdated, &totalSize,
&overallStatus, &fa.AnalyticsLastUpdated, &analytics.Latitude, // Scan directly into the struct fields
&analytics.Longitude,
&weatherJSON,
&inventoryJSON,
&cropJSON,
&overallStatus,
&analytics.AnalyticsLastUpdated,
) )
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
return nil, domain.ErrNotFound // Use domain error 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) r.logger.Error("Failed to query farm analytics", "farm_id", farmID, "error", err)
return nil, fmt.Errorf("database error fetching analytics for farm %s: %w", farmID, err) return nil, fmt.Errorf("database query failed for farm analytics: %w", err)
} }
fa.Weather = &domain.WeatherData{ // Handle nullable fields
TempCelsius: weatherTemp, if farmType.Valid {
Humidity: weatherHumid, analytics.FarmType = &farmType.String
Description: weatherDesc, }
Icon: weatherIcon, if totalSize.Valid {
WindSpeed: weatherWind, analytics.TotalSize = &totalSize.String
RainVolume1h: weatherRain, }
ObservedAt: weatherObservedAt, if overallStatus.Valid {
WeatherLastUpdated: weatherLastUpdated, 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 := ` query := `
INSERT INTO public.farm_analytics ( SELECT
farm_id, farm_name, owner_id, farm_type, total_size, latitude, longitude, analytics_last_updated c.uuid, c.name, c.farm_id, c.status, c.growth_stage, c.land_size, c.updated_at,
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) p.name, p.variety, p.days_to_maturity,
ON CONFLICT (farm_id) DO UPDATE SET c.created_at -- Needed for growth calculation
farm_name = EXCLUDED.farm_name, FROM
croplands c
JOIN
plants p ON c.plant_id = p.uuid
WHERE
c.uuid = $1
`
var analytics domain.CropAnalytics
var plantName string
var variety sql.NullString
var daysToMaturity sql.NullInt32
var plantedAt time.Time // Use cropland created_at as planted date proxy
// Use r.conn here
err := r.conn.QueryRow(ctx, query, cropID).Scan(
&analytics.CropID,
&analytics.CropName,
&analytics.FarmID,
&analytics.CurrentStatus,
&analytics.GrowthStage,
&analytics.LandSize,
&analytics.LastUpdated,
&plantName,
&variety,
&daysToMaturity,
&plantedAt,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) { // Check pgx error too
r.logger.Warn("Crop analytics query returned no rows", "crop_id", cropID)
return nil, domain.ErrNotFound
}
r.logger.Error("Failed to query crop analytics data", "crop_id", cropID, "error", err)
return nil, fmt.Errorf("database query failed for crop analytics: %w", err)
}
analytics.PlantName = plantName
if variety.Valid {
analytics.Variety = &variety.String
}
// Calculate Growth Progress
var maturityDaysPtr *int
if daysToMaturity.Valid {
maturityInt := int(daysToMaturity.Int32)
maturityDaysPtr = &maturityInt
}
analytics.GrowthProgress = calculateGrowthProgress(plantedAt, maturityDaysPtr)
// Fetch Farm Analytics to get weather
farmAnalytics, err := r.GetFarmAnalytics(ctx, analytics.FarmID) // Call using r receiver
if err == nil && farmAnalytics != nil && farmAnalytics.Weather != nil {
analytics.Temperature = farmAnalytics.Weather.TempCelsius
analytics.Humidity = farmAnalytics.Weather.Humidity
windSpeedStr := fmt.Sprintf("%.2f", *farmAnalytics.Weather.WindSpeed)
analytics.WindSpeed = &windSpeedStr
if farmAnalytics.Weather.RainVolume1h != nil {
rainStr := fmt.Sprintf("%.2f", *farmAnalytics.Weather.RainVolume1h)
analytics.Rainfall = &rainStr
}
analytics.Sunlight = nil // Placeholder - requires source
analytics.SoilMoisture = nil // Placeholder - requires source
} else if err != nil && !errors.Is(err, domain.ErrNotFound) {
r.logger.Warn("Could not fetch farm analytics for weather data", "farm_id", analytics.FarmID, "error", err)
}
// Placeholder for other fields
analytics.PlantHealth = new(string) // Initialize pointer
*analytics.PlantHealth = "good" // Default/placeholder value
analytics.NextAction = nil
analytics.NextActionDue = nil
analytics.NutrientLevels = nil // Needs dedicated data source
r.logger.Debug("Successfully retrieved crop analytics", "crop_id", cropID, "farm_id", analytics.FarmID)
return &analytics, nil
}
// --- Implement other AnalyticsRepository methods ---
func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error {
query := `
INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, lat, lon, last_updated)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (farm_id) DO UPDATE
SET farm_name = EXCLUDED.farm_name,
owner_id = EXCLUDED.owner_id, owner_id = EXCLUDED.owner_id,
farm_type = EXCLUDED.farm_type, farm_type = EXCLUDED.farm_type,
total_size = EXCLUDED.total_size, total_size = EXCLUDED.total_size,
latitude = EXCLUDED.latitude, lat = EXCLUDED.lat,
longitude = EXCLUDED.longitude, lon = EXCLUDED.lon,
analytics_last_updated = NOW()` last_updated = EXCLUDED.last_updated;`
_, err := r.pool.Exec(ctx, query, _, err := r.conn.Exec(ctx, query,
farm.UUID, farm.Name, farm.OwnerID, farm.FarmType, farm.TotalSize, farm.Lat, farm.Lon, 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 { if err != nil {
r.logger.Error("Error upserting farm base analytics", "farm_id", farm.UUID, "error", err) r.logger.Error("Failed to create/update farm base analytics data", "farm_id", farm.UUID, "error", err)
return fmt.Errorf("failed to save base farm analytics for %s: %w", farm.UUID, err) return fmt.Errorf("failed to upsert farm base data: %w", err)
} }
r.logger.Debug("Upserted farm base analytics", "farm_id", farm.UUID) r.logger.Debug("Upserted farm base analytics data", "farm_id", farm.UUID)
return nil return nil
} }
func (r *PostgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error { func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsWeather(ctx context.Context, farmID string, weatherData *domain.WeatherData) error {
if weatherData == nil { if weatherData == nil {
return errors.New("weather data cannot be nil for update") return fmt.Errorf("weather data cannot be nil")
} }
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, weatherJSON, err := json.Marshal(weatherData)
farmID,
weatherData.TempCelsius,
weatherData.Humidity,
weatherData.Description,
weatherData.Icon,
weatherData.WindSpeed,
weatherData.RainVolume1h,
weatherData.ObservedAt,
)
if err != nil { if err != nil {
r.logger.Error("Error updating farm weather analytics", "farm_id", farmID, "error", err) r.logger.Error("Failed to marshal weather data for analytics update", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to update weather analytics for farm %s: %w", farmID, err) return fmt.Errorf("failed to marshal weather data: %w", err)
} }
r.logger.Debug("Updated farm weather analytics", "farm_id", farmID)
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 return nil
} }
func (r *PostgresFarmAnalyticsRepository) UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error { // UpdateFarmAnalyticsCropStats needs to query the croplands table for the farm
countQuery := ` 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 SELECT
COUNT(*), COUNT(*),
COUNT(*) FILTER (WHERE lower(status) = 'growing') COUNT(*) FILTER (WHERE status = 'growing') -- Case-insensitive comparison if needed: LOWER(status) = 'growing'
FROM public.croplands FROM croplands
WHERE farm_id = $1 WHERE farm_id = $1;`
`
var totalCount, growingCount int err := r.conn.QueryRow(ctx, query, farmID).Scan(&totalCount, &growingCount)
err := r.pool.QueryRow(ctx, countQuery, farmID).Scan(&totalCount, &growingCount)
if err != nil { if err != nil {
if !errors.Is(err, pgx.ErrNoRows) { // Log error but don't fail the projection if stats can't be calculated temporarily
r.logger.Error("Error calculating crop counts", "farm_id", farmID, "error", err) r.logger.Error("Failed to calculate crop stats for analytics", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to calculate crop stats for farm %s: %w", farmID, 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 := ` updateQuery := `
UPDATE public.farm_analytics SET UPDATE farm_analytics
crop_total_count = $2, SET crop_data = $1,
crop_growing_count = $3, last_updated = $2 -- Also update the main last_updated timestamp
crop_last_updated = NOW(), WHERE farm_id = $3;`
analytics_last_updated = NOW()
WHERE farm_id = $1`
cmdTag, err := r.pool.Exec(ctx, updateQuery, farmID, totalCount, growingCount) cmdTag, err := r.conn.Exec(ctx, updateQuery, cropJSON, time.Now().UTC(), farmID)
if err != nil { if err != nil {
r.logger.Error("Error updating farm crop stats", "farm_id", farmID, "error", err) r.logger.Error("Failed to update farm analytics crop stats", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to update crop stats for farm %s: %w", farmID, err) return fmt.Errorf("database update failed for crop stats: %w", err)
} }
if cmdTag.RowsAffected() == 0 { if cmdTag.RowsAffected() == 0 {
r.logger.Warn("No farm analytics record found to update crop stats", "farm_id", farmID) 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 // Optionally, create the base record here
// return r.CreateOrUpdateFarmBaseData(ctx, &domain.Farm{UUID: farmID /* Fetch other details */}) } 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 return nil
} }
// TODO: Implement actual count calculation if needed later. // UpdateFarmAnalyticsInventoryStats needs to query inventory_items
func (r *PostgresFarmAnalyticsRepository) UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error { func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsInventoryStats(ctx context.Context, farmID string) error {
query := ` var totalItems, lowStockCount int
UPDATE public.farm_analytics SET var lastUpdated sql.NullTime
-- 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`
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 { if err != nil {
r.logger.Error("Error touching inventory timestamp in farm analytics", "farm_id", farmID, "error", err) if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
return fmt.Errorf("failed to update inventory stats timestamp for farm %s: %w", farmID, err) 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 { 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 return nil
} }
func (r *PostgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Context, farmID string) error { func (r *postgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Context, farmID string) error {
query := `DELETE FROM public.farm_analytics WHERE farm_id = $1` query := `DELETE FROM farm_analytics WHERE farm_id = $1;`
_, err := r.pool.Exec(ctx, query, farmID) cmdTag, err := r.conn.Exec(ctx, query, farmID)
if err != nil { if err != nil {
r.logger.Error("Error deleting farm analytics", "farm_id", farmID, "error", err) r.logger.Error("Failed to delete farm analytics data", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to delete analytics for farm %s: %w", farmID, 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 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 := ` query := `
UPDATE public.farm_analytics SET UPDATE farm_analytics
overall_status = $2, SET overall_status = $1,
analytics_last_updated = NOW() last_updated = $2
WHERE farm_id = $1` 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 { if err != nil {
r.logger.Error("Error updating farm overall status", "farm_id", farmID, "status", status, "error", err) r.logger.Error("Failed to update farm overall status", "farm_id", farmID, "status", status, "error", err)
return fmt.Errorf("failed to update overall status for farm %s: %w", farmID, err) return fmt.Errorf("database update failed for overall status: %w", err)
} }
if cmdTag.RowsAffected() == 0 { if cmdTag.RowsAffected() == 0 {
r.logger.Warn("No farm analytics record found to update overall status", "farm_id", farmID) 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) r.logger.Debug("Updated farm overall status", "farm_id", farmID, "status", status)
return nil return nil

View File

@ -1,33 +1,54 @@
import axiosInstance from "./config"; import axiosInstance from "./config";
import type { Cropland } from "@/types"; // Use refactored types
import type { Cropland, CropAnalytics } from "@/types";
export interface CropResponse { export interface CropResponse {
croplands: Cropland[]; croplands: Cropland[];
} }
/** /**
* Fetch a specific Crop by FarmID. * Fetch all Croplands for a specific FarmID. Returns CropResponse.
* Calls GET /crop/farm/{farm_id} and returns fallback data on failure.
*/ */
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); return axiosInstance.get<CropResponse>(`/crop/farm/${farmId}`).then((res) => res.data);
} }
// body /**
// { * Fetch a specific Cropland by its ID. Returns Cropland.
// "farm_id": "string", */
// "growth_stage": "string", export async function getCropById(cropId: string): Promise<Cropland> {
// "land_size": 0, // Assuming backend returns { "cropland": ... }
// "name": "string", return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
// "plant_id": "string", // If backend returns object directly: return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
// "priority": 0, }
// "status": "string",
// }
/** /**
* Create a new crop by FarmID. * Create a new crop (Cropland). Sends camelCase data matching backend tags. Returns Cropland.
* Calls POST /crop and returns fallback data on failure.
*/ */
export async function createCrop(data: Partial<Cropland>): Promise<Cropland> { export async function createCrop(data: Partial<Omit<Cropland, "uuid" | "createdAt" | "updatedAt">>): Promise<Cropland> {
return axiosInstance.post<Cropland>(`/crop`, data).then((res) => res.data); 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);
} }

View File

@ -1,80 +1,63 @@
import axiosInstance from "./config"; import axiosInstance from "./config";
// Use the refactored Farm type
import type { Farm } from "@/types"; import type { Farm } from "@/types";
/** /**
* Fetch an array of farms. * Fetch an array of farms. Returns Farm[].
* Calls GET /farms and returns fallback dummy data on failure.
*/ */
export async function fetchFarms(): Promise<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. * Create a new farm. Sends camelCase data. Returns Farm.
* Calls POST /farms with a payload that uses snake_case keys.
*/ */
export async function createFarm(data: Partial<Farm>): Promise<Farm> { export async function createFarm(
return axiosInstance.post<Farm>("/farms", data).then((res) => res.data); 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. * Fetch a specific farm by ID. Returns Farm.
* Calls GET /farms/{farm_id} and returns fallback data on failure.
*/ */
export async function getFarm(farmId: string): Promise<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. * Update an existing farm. Sends camelCase data. Returns Farm.
* Calls PUT /farms/{farm_id} with a snake_case payload.
*/ */
export async function updateFarm( export async function updateFarm(
farmId: string, farmId: string,
data: { data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>
farm_type: string;
lat: number;
lon: number;
name: string;
total_size: string;
}
): Promise<Farm> { ): Promise<Farm> {
// Simulate a network delay. // Construct payload matching backend expected camelCase tags
await new Promise((resolve) => setTimeout(resolve, 800)); const payload = {
name: data.name,
try { lat: data.lat,
const response = await axiosInstance.put<Farm>(`/farms/${farmId}`, data); lon: data.lon,
return response.data; farmType: data.farmType,
} catch (error: any) { totalSize: data.totalSize,
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,
}; };
} return axiosInstance.put<Farm>(`/farms/${farmId}`, payload).then((res) => res.data);
} }
/** /**
* Delete a specific farm. * Delete a specific farm. Returns { message: string }.
* Calls DELETE /farms/{farm_id} and returns a success message.
*/ */
export async function deleteFarm(farmId: string): Promise<{ message: string }> { export async function deleteFarm(farmId: string): Promise<{ message: string }> {
// Simulate a network delay. return axiosInstance.delete(`/farms/${farmId}`).then((res) => res.data);
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)" };
}
} }