Merge pull request #26 from ForFarmTeam/feature-farm-setup

Add chatbot, openweather projection and edit/delete farm and crops
This commit is contained in:
Sirin Puenggun 2025-04-04 16:40:19 +07:00 committed by GitHub
commit 8b83a26c15
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 6857 additions and 4458 deletions

View File

@ -9,6 +9,7 @@ require (
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/gofrs/uuid v4.4.0+incompatible github.com/gofrs/uuid v4.4.0+incompatible
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/generative-ai-go v0.19.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2 github.com/jackc/pgx/v5 v5.7.2
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
@ -17,12 +18,28 @@ require (
github.com/rabbitmq/amqp091-go v1.10.0 github.com/rabbitmq/amqp091-go v1.10.0
github.com/spf13/cobra v1.8.1 github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0 github.com/spf13/viper v1.19.0
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.36.0
google.golang.org/api v0.186.0
) )
require ( require (
cloud.google.com/go v0.115.0 // indirect
cloud.google.com/go/ai v0.8.0 // indirect
cloud.google.com/go/auth v0.6.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 // indirect github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.5 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
@ -41,11 +58,24 @@ require (
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
go.opentelemetry.io/otel v1.26.0 // indirect
go.opentelemetry.io/otel/metric v1.26.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 // indirect
google.golang.org/grpc v1.64.1 // indirect
google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

View File

@ -1,5 +1,22 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/ai v0.8.0 h1:rXUEz8Wp2OlrM8r1bfmpF2+VKqc1VJpafE3HgzRnD/w=
cloud.google.com/go/ai v0.8.0/go.mod h1:t3Dfk4cM61sytiggo2UyGsDVW3RF1qGZaUKDrZFyqkE=
cloud.google.com/go/auth v0.6.0 h1:5x+d6b5zdezZ7gmLWD1m/xNjnaQ2YDhmIz/HH3doy1g=
cloud.google.com/go/auth v0.6.0/go.mod h1:b4acV+jLQDyjwm4OXHYjNvRi4jvGBzHWJRtJcy+2P4g=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/danielgtaylor/huma/v2 v2.28.0 h1:W+hIT52MigO73edJNJWXU896uC99xSBWpKoE2PRyybM= github.com/danielgtaylor/huma/v2 v2.28.0 h1:W+hIT52MigO73edJNJWXU896uC99xSBWpKoE2PRyybM=
github.com/danielgtaylor/huma/v2 v2.28.0/go.mod h1:67KO0zmYEkR+LVUs8uqrcvf44G1wXiMIu94LV/cH2Ek= github.com/danielgtaylor/huma/v2 v2.28.0/go.mod h1:67KO0zmYEkR+LVUs8uqrcvf44G1wXiMIu94LV/cH2Ek=
@ -9,6 +26,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@ -17,16 +40,52 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/generative-ai-go v0.19.0 h1:R71szggh8wHMCUlEMsW2A/3T+5LdEIkiaHSYgSpUgdg=
github.com/google/generative-ai-go v0.19.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.5 h1:8gw9KZK8TiVKB6q3zHY3SBzLnrGp6HQjyfYBYGmXdxA=
github.com/googleapis/gax-go/v2 v2.12.5/go.mod h1:BUDKcWo+RaKq5SC9vVYL0wLADa3VcfswbOMMRmB9H3E=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
@ -67,6 +126,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY= github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@ -94,27 +154,107 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.186.0 h1:n2OPp+PPXX0Axh4GuSsL5QL8xQCTb2oDwyzPnQvqUug=
google.golang.org/api v0.186.0/go.mod h1:hvRbBmgoje49RV3xqVXrmP6w93n6ehGgIVPYrGtBFFc=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4 h1:MuYw1wJzT+ZkybKfaOXKp5hJiZDn2iHaXRw0mRYdHSc=
google.golang.org/genproto/googleapis/api v0.0.0-20240617180043-68d350f18fd4/go.mod h1:px9SlOOZBg1wM1zdnr8jEL4CNGUBZ+ZKYtNPApNQc4c=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4 h1:Di6ANFilr+S60a4S61ZM00vLdw0IrQOSMS2/6mrnOU0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240617180043-68d350f18fd4/go.mod h1:Ue6ibwXGpU+dqIcODieyLOcgj7z8+IcskoNIgZxtrFY=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@ -124,6 +264,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=

View File

@ -20,6 +20,7 @@ import (
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
m "github.com/forfarm/backend/internal/middlewares" m "github.com/forfarm/backend/internal/middlewares"
"github.com/forfarm/backend/internal/repository" "github.com/forfarm/backend/internal/repository"
"github.com/forfarm/backend/internal/services"
"github.com/forfarm/backend/internal/services/weather" "github.com/forfarm/backend/internal/services/weather"
"github.com/forfarm/backend/internal/utilities" "github.com/forfarm/backend/internal/utilities"
) )
@ -29,22 +30,22 @@ type api struct {
httpClient *http.Client httpClient *http.Client
eventPublisher domain.EventPublisher eventPublisher domain.EventPublisher
userRepo domain.UserRepository userRepo domain.UserRepository
cropRepo domain.CroplandRepository cropRepo domain.CroplandRepository
farmRepo domain.FarmRepository farmRepo domain.FarmRepository
plantRepo domain.PlantRepository plantRepo domain.PlantRepository
inventoryRepo domain.InventoryRepository inventoryRepo domain.InventoryRepository
harvestRepo domain.HarvestRepository harvestRepo domain.HarvestRepository
analyticsRepo domain.AnalyticsRepository analyticsRepo domain.AnalyticsRepository
knowledgeHubRepo domain.KnowledgeHubRepository knowledgeHubRepo domain.KnowledgeHubRepository
weatherFetcher domain.WeatherFetcher weatherFetcher domain.WeatherFetcher
chatService *services.ChatService
} }
var weatherFetcherInstance domain.WeatherFetcher func (a *api) GetWeatherFetcher() domain.WeatherFetcher {
return a.weatherFetcher
func GetWeatherFetcher() domain.WeatherFetcher {
return weatherFetcherInstance
} }
func NewAPI( func NewAPI(
@ -63,7 +64,6 @@ func NewAPI(
userRepository := repository.NewPostgresUser(pool) userRepository := repository.NewPostgresUser(pool)
plantRepository := repository.NewPostgresPlant(pool) plantRepository := repository.NewPostgresPlant(pool)
knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool) knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool)
inventoryRepository := repository.NewPostgresInventory(pool)
harvestRepository := repository.NewPostgresHarvest(pool) harvestRepository := repository.NewPostgresHarvest(pool)
owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger) owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger)
@ -77,22 +77,29 @@ func NewAPI(
cleanupInterval = 5 * time.Minute cleanupInterval = 5 * time.Minute
} }
cachedWeatherFetcher := weather.NewCachedWeatherFetcher(owmFetcher, cacheTTL, cleanupInterval, logger) cachedWeatherFetcher := weather.NewCachedWeatherFetcher(owmFetcher, cacheTTL, cleanupInterval, logger)
weatherFetcherInstance = cachedWeatherFetcher
chatService, chatErr := services.NewChatService(logger, analyticsRepo, farmRepo, croplandRepo, inventoryRepo, plantRepository)
if chatErr != nil {
logger.Error("Failed to initialize ChatService", "error", chatErr)
chatService = nil
}
return &api{ return &api{
logger: logger, logger: logger,
httpClient: client, httpClient: client,
eventPublisher: eventPublisher, eventPublisher: eventPublisher,
userRepo: userRepository, userRepo: userRepository,
cropRepo: croplandRepo, cropRepo: croplandRepo,
farmRepo: farmRepo, farmRepo: farmRepo,
plantRepo: plantRepository, plantRepo: plantRepository,
inventoryRepo: inventoryRepo, inventoryRepo: inventoryRepo,
harvestRepo: harvestRepository, harvestRepo: harvestRepository,
analyticsRepo: analyticsRepo, analyticsRepo: analyticsRepo,
knowledgeHubRepo: knowledgeHubRepository, knowledgeHubRepo: knowledgeHubRepository,
weatherFetcher: cachedWeatherFetcher, weatherFetcher: cachedWeatherFetcher,
chatService: chatService,
} }
} }
@ -135,6 +142,7 @@ func (a *api) Routes() *chi.Mux {
a.registerPlantRoutes(r, api) a.registerPlantRoutes(r, api)
a.registerKnowledgeHubRoutes(r, api) a.registerKnowledgeHubRoutes(r, api)
a.registerOauthRoutes(r, api) a.registerOauthRoutes(r, api)
a.registerChatRoutes(r, api)
a.registerInventoryRoutes(r, api) a.registerInventoryRoutes(r, api)
}) })

View File

@ -0,0 +1,142 @@
package api
import (
"context"
"net/http"
"github.com/danielgtaylor/huma/v2"
"github.com/forfarm/backend/internal/services"
"github.com/go-chi/chi/v5"
"github.com/google/generative-ai-go/genai"
)
func (a *api) registerChatRoutes(_ chi.Router, apiInstance huma.API) {
tags := []string{"chat"}
huma.Register(apiInstance, huma.Operation{
OperationID: "chatWithAssistantContextual",
Method: http.MethodPost,
Path: "/chat/specific",
Tags: tags,
Summary: "Send a message to the assistant with farm/crop context",
Description: "Allows users to interact with the AI chatbot, providing farm/crop context.",
}, a.chatHandler)
huma.Register(apiInstance, huma.Operation{
OperationID: "chatWithAssistantGeneral",
Method: http.MethodPost,
Path: "/chat",
Tags: tags,
Summary: "Send a message to the general farming assistant",
Description: "Allows users to interact with the AI chatbot without specific farm/crop context.",
}, a.generalChatHandler)
}
type HistoryItem struct {
Role string `json:"role" example:"user" enum:"user,model" doc:"Role of the sender (user or model)"`
Text string `json:"text" doc:"The content of the message"`
}
type ChatInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
Body struct {
Message string `json:"message" required:"true" doc:"The user's message to the assistant"`
FarmID string `json:"farmId,omitempty" doc:"Optional UUID of the farm context"`
CropID string `json:"cropId,omitempty" doc:"Optional UUID of the crop context"`
History []HistoryItem `json:"history,omitempty" doc:"Previous turns in the conversation"`
}
}
type ChatOutput struct {
Body struct {
Response string `json:"response" doc:"The assistant's response message"`
}
}
type GeneralChatInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
Body struct {
Message string `json:"message" required:"true" doc:"The user's message to the assistant"`
History []HistoryItem `json:"history,omitempty" doc:"Previous turns in the conversation"`
}
}
type GeneralChatOutput = ChatOutput
// convertInputHistory converts the API HistoryItem slice to genai.Content slice.
func convertInputHistory(apiHistory []HistoryItem) []*genai.Content {
if apiHistory == nil {
return nil
}
genaiHistory := make([]*genai.Content, 0, len(apiHistory))
for _, item := range apiHistory {
if (item.Role == "user" || item.Role == "model") && item.Text != "" {
content := &genai.Content{
Role: item.Role,
Parts: []genai.Part{genai.Text(item.Text)},
}
genaiHistory = append(genaiHistory, content)
}
}
return genaiHistory
}
func (a *api) chatHandler(ctx context.Context, input *ChatInput) (*ChatOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err)
}
if a.chatService == nil {
a.logger.Error("Chat service is not initialized")
return nil, huma.Error500InternalServerError("Chat service is not available")
}
genaiHistory := convertInputHistory(input.Body.History)
serviceInput := services.GenerateResponseInput{
UserID: userID,
Message: input.Body.Message,
FarmID: input.Body.FarmID,
CropID: input.Body.CropID,
History: genaiHistory,
}
botResponse, err := a.chatService.GenerateResponse(ctx, serviceInput)
if err != nil {
a.logger.Error("Failed to get response from chat service (contextual)", "userId", userID, "farmId", input.Body.FarmID, "cropId", input.Body.CropID, "error", err)
return nil, huma.Error500InternalServerError("Failed to get response from assistant")
}
resp := &ChatOutput{}
resp.Body.Response = botResponse
return resp, nil
}
func (a *api) generalChatHandler(ctx context.Context, input *GeneralChatInput) (*GeneralChatOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err)
}
if a.chatService == nil {
a.logger.Error("Chat service is not initialized")
return nil, huma.Error500InternalServerError("Chat service is not available")
}
genaiHistory := convertInputHistory(input.Body.History)
serviceInput := services.GenerateResponseInput{
UserID: userID,
Message: input.Body.Message,
History: genaiHistory,
}
botResponse, err := a.chatService.GenerateResponse(ctx, serviceInput)
if err != nil {
a.logger.Error("Failed to get response from chat service (general)", "userId", userID, "error", err)
return nil, huma.Error500InternalServerError("Failed to get response from assistant")
}
resp := &GeneralChatOutput{}
resp.Body.Response = botResponse
return resp, nil
}

View File

@ -15,10 +15,8 @@ import (
func (a *api) registerCropRoutes(_ chi.Router, api huma.API) { func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
tags := []string{"crop"} tags := []string{"crop"}
prefix := "/crop" prefix := "/crop"
// Register GET /crop
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getAllCroplands", OperationID: "getAllCroplands",
Method: http.MethodGet, Method: http.MethodGet,
@ -26,7 +24,6 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
Tags: tags, Tags: tags,
}, a.getAllCroplandsHandler) }, a.getAllCroplandsHandler)
// Register GET /crop/{uuid}
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getCroplandByID", OperationID: "getCroplandByID",
Method: http.MethodGet, Method: http.MethodGet,
@ -34,7 +31,6 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
Tags: tags, Tags: tags,
}, a.getCroplandByIDHandler) }, a.getCroplandByIDHandler)
// Register GET /crop/farm/{farm_id}
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "getAllCroplandsByFarmID", OperationID: "getAllCroplandsByFarmID",
Method: http.MethodGet, Method: http.MethodGet,
@ -42,15 +38,23 @@ func (a *api) registerCropRoutes(_ chi.Router, api huma.API) {
Tags: tags, Tags: tags,
}, a.getAllCroplandsByFarmIDHandler) }, a.getAllCroplandsByFarmIDHandler)
// Register POST /crop (Create or Update)
huma.Register(api, huma.Operation{ huma.Register(api, huma.Operation{
OperationID: "createOrUpdateCropland", OperationID: "createCropland",
Method: http.MethodPost, Method: http.MethodPost,
Path: prefix, Path: prefix,
Tags: tags, Tags: tags,
}, a.createOrUpdateCroplandHandler) }, a.createCroplandHandler)
huma.Register(api, huma.Operation{
OperationID: "updateCropland",
Method: http.MethodPut,
Path: prefix + "/{uuid}",
Tags: tags,
}, a.updateCroplandHandler)
} }
// --- Common Output Structs ---
type GetCroplandsOutput struct { type GetCroplandsOutput struct {
Body struct { Body struct {
Croplands []domain.Cropland `json:"croplands"` Croplands []domain.Cropland `json:"croplands"`
@ -63,22 +67,45 @@ type GetCroplandByIDOutput struct {
} }
} }
type CreateOrUpdateCroplandInput struct { // --- Create Structs ---
type CreateCroplandInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
Body struct { Body struct {
UUID string `json:"uuid,omitempty"` Name string `json:"name" required:"true"`
Name string `json:"name"` Status string `json:"status" required:"true"`
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" required:"true"`
PlantID string `json:"plantId"` PlantID string `json:"plantId" required:"true" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"`
FarmID string `json:"farmId"` FarmID string `json:"farmId" required:"true" example:"b2c3d4e5-f6a7-8901-2345-67890abcdef0"`
GeoFeature json.RawMessage `json:"geoFeature,omitempty"` GeoFeature json.RawMessage `json:"geoFeature,omitempty"`
} }
} }
type CreateOrUpdateCroplandOutput struct { type CreateCroplandOutput struct {
Body struct {
Cropland domain.Cropland `json:"cropland"`
}
}
// --- Update Structs ---
type UpdateCroplandInput struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"`
UUID string `path:"uuid" required:"true" example:"c3d4e5f6-a7b8-9012-3456-7890abcdef01"`
Body struct {
Name string `json:"name" required:"true"`
Status string `json:"status" required:"true"`
Priority int `json:"priority"`
LandSize float64 `json:"landSize"`
GrowthStage string `json:"growthStage" required:"true"`
PlantID string `json:"plantId" required:"true" example:"a1b2c3d4-e5f6-7890-1234-567890abcdef"`
GeoFeature json.RawMessage `json:"geoFeature,omitempty"`
}
}
type UpdateCroplandOutput struct {
Body struct { Body struct {
Cropland domain.Cropland `json:"cropland"` Cropland domain.Cropland `json:"cropland"`
} }
@ -90,8 +117,7 @@ func (a *api) getAllCroplandsHandler(ctx context.Context, input *struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` Header string `header:"Authorization" required:"true" example:"Bearer token"`
}) (*GetCroplandsOutput, error) { }) (*GetCroplandsOutput, error) {
// Note: This currently fetches ALL croplands. Might need owner filtering later. // Note: This currently fetches ALL croplands. Might need owner filtering later.
// For now, ensure authentication happens. _, err := a.getUserIDFromHeader(input.Header)
_, err := a.getUserIDFromHeader(input.Header) // Verify token
if err != nil { if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err) return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
@ -112,7 +138,7 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
Header string `header:"Authorization" required:"true" example:"Bearer token"` 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 userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err) return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
@ -120,15 +146,15 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
resp := &GetCroplandByIDOutput{} resp := &GetCroplandByIDOutput{}
if input.UUID == "" { if input.UUID == "" {
return nil, huma.Error400BadRequest("UUID parameter is required") return nil, huma.Error400BadRequest("UUID path parameter is required")
} }
_, err = uuid.FromString(input.UUID) croplandUUID, 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, croplandUUID.String())
if err != nil { if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
a.logger.Warn("Cropland not found", "croplandId", input.UUID, "requestingUserId", userID) a.logger.Warn("Cropland not found", "croplandId", input.UUID, "requestingUserId", userID)
@ -138,12 +164,10 @@ func (a *api) getCroplandByIDHandler(ctx context.Context, input *struct {
return nil, huma.Error500InternalServerError("Failed to retrieve cropland") 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)
farm, err := a.farmRepo.GetByID(ctx, cropland.FarmID) // Fetch the farm
if err != nil { if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { 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) 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") 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) a.logger.Error("Failed to fetch farm for cropland authorization", "farmId", cropland.FarmID, "error", err)
@ -171,7 +195,7 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct
resp := &GetCroplandsOutput{} resp := &GetCroplandsOutput{}
if input.FarmID == "" { if input.FarmID == "" {
return nil, huma.Error400BadRequest("farm_id parameter is required") return nil, huma.Error400BadRequest("farmId path parameter is required")
} }
farmUUID, err := uuid.FromString(input.FarmID) farmUUID, err := uuid.FromString(input.FarmID)
@ -179,7 +203,6 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct
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()) farm, err := a.farmRepo.GetByID(ctx, farmUUID.String())
if err != nil { if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
@ -208,83 +231,41 @@ func (a *api) getAllCroplandsByFarmIDHandler(ctx context.Context, input *struct
return resp, nil return resp, nil
} }
func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOrUpdateCroplandInput) (*CreateOrUpdateCroplandOutput, error) { func (a *api) createCroplandHandler(ctx context.Context, input *CreateCroplandInput) (*CreateCroplandOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header) userID, err := a.getUserIDFromHeader(input.Header)
if err != nil { if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err) return nil, huma.Error401Unauthorized("Authentication failed", err)
} }
resp := &CreateOrUpdateCroplandOutput{} resp := &CreateCroplandOutput{}
// --- Input Validation ---
if input.Body.Name == "" {
return nil, huma.Error400BadRequest("name is required")
}
if input.Body.Status == "" {
return nil, huma.Error400BadRequest("status is required")
}
if input.Body.GrowthStage == "" {
return nil, huma.Error400BadRequest("growthStage is required")
}
if input.Body.PlantID == "" {
return nil, huma.Error400BadRequest("plantId is required")
}
if input.Body.FarmID == "" {
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 { if _, err := uuid.FromString(input.Body.PlantID); err != nil {
return nil, huma.Error400BadRequest("invalid plantId UUID format") return nil, huma.Error400BadRequest("invalid plantId UUID format")
} }
farmUUID, err := uuid.FromString(input.Body.FarmID) farmUUID, err := uuid.FromString(input.Body.FarmID)
if err != nil { if err != nil {
return nil, huma.Error400BadRequest("invalid farm_id UUID format") return nil, huma.Error400BadRequest("invalid farmId 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 geoFeature") return nil, huma.Error400BadRequest("invalid JSON format for geoFeature")
} }
// --- Authorization Check ---
// User must own the farm they are adding/updating a crop for
farm, err := a.farmRepo.GetByID(ctx, farmUUID.String()) farm, err := a.farmRepo.GetByID(ctx, farmUUID.String())
if err != nil { if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) { 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) a.logger.Warn("Attempt to create crop for non-existent farm", "farmId", input.Body.FarmID, "requestingUserId", userID)
return nil, huma.Error404NotFound("Target farm not found") 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) a.logger.Error("Failed to fetch farm for create cropland authorization", "farmId", input.Body.FarmID, "error", err)
return nil, huma.Error500InternalServerError("Failed to verify ownership") return nil, huma.Error500InternalServerError("Failed to verify ownership")
} }
if farm.OwnerID != userID { if farm.OwnerID != userID {
a.logger.Warn("Unauthorized attempt to create/update crop on farm", "farmId", input.Body.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID) a.logger.Warn("Unauthorized attempt to create 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") return nil, huma.Error403Forbidden("You are not authorized to add crops to 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,
Name: input.Body.Name, Name: input.Body.Name,
Status: input.Body.Status, Status: input.Body.Status,
Priority: input.Body.Priority, Priority: input.Body.Priority,
@ -295,15 +276,84 @@ func (a *api) createOrUpdateCroplandHandler(ctx context.Context, input *CreateOr
GeoFeature: input.Body.GeoFeature, GeoFeature: input.Body.GeoFeature,
} }
// Use the repository's CreateOrUpdate which handles assigning UUID if needed
err = a.cropRepo.CreateOrUpdate(ctx, cropland) err = a.cropRepo.CreateOrUpdate(ctx, cropland)
if err != nil { if err != nil {
a.logger.Error("Failed to save cropland to database", "farm_id", input.Body.FarmID, "plantId", input.Body.PlantID, "error", err) a.logger.Error("Failed to create cropland in database", "farmId", input.Body.FarmID, "plantId", input.Body.PlantID, "error", err)
return nil, huma.Error500InternalServerError("Failed to save cropland") return nil, huma.Error500InternalServerError("Failed to save cropland")
} }
a.logger.Info("Cropland created/updated successfully", "croplandId", cropland.UUID, "farmId", cropland.FarmID) a.logger.Info("Cropland created successfully", "croplandId", cropland.UUID, "farmId", cropland.FarmID)
resp.Body.Cropland = *cropland resp.Body.Cropland = *cropland
return resp, nil return resp, nil
} }
func (a *api) updateCroplandHandler(ctx context.Context, input *UpdateCroplandInput) (*UpdateCroplandOutput, error) {
userID, err := a.getUserIDFromHeader(input.Header)
if err != nil {
return nil, huma.Error401Unauthorized("Authentication failed", err)
}
resp := &UpdateCroplandOutput{}
croplandUUID, err := uuid.FromString(input.UUID)
if err != nil {
return nil, huma.Error400BadRequest("Invalid cropland UUID format in path")
}
if _, err := uuid.FromString(input.Body.PlantID); err != nil {
return nil, huma.Error400BadRequest("invalid plantId UUID format in body")
}
if input.Body.GeoFeature != nil && !json.Valid(input.Body.GeoFeature) {
return nil, huma.Error400BadRequest("invalid JSON format for geoFeature")
}
existingCrop, err := a.cropRepo.GetByID(ctx, croplandUUID.String())
if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
a.logger.Warn("Attempt to update non-existent cropland", "croplandId", input.UUID, "requestingUserId", userID)
return nil, huma.Error404NotFound("Cropland not found")
}
a.logger.Error("Failed to get existing cropland for update", "croplandId", input.UUID, "error", err)
return nil, huma.Error500InternalServerError("Failed to retrieve cropland for update")
}
farm, err := a.farmRepo.GetByID(ctx, existingCrop.FarmID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) || errors.Is(err, sql.ErrNoRows) {
a.logger.Error("Farm associated with existing cropland not found during update", "farmId", existingCrop.FarmID, "croplandId", input.UUID)
return nil, huma.Error500InternalServerError("Associated farm data inconsistent")
}
a.logger.Error("Failed to fetch farm for update cropland authorization", "farmId", existingCrop.FarmID, "error", err)
return nil, huma.Error500InternalServerError("Failed to verify ownership for update")
}
if farm.OwnerID != userID {
a.logger.Warn("Unauthorized attempt to update crop on farm", "croplandId", input.UUID, "farmId", existingCrop.FarmID, "requestingUserId", userID, "farmOwnerId", farm.OwnerID)
return nil, huma.Error403Forbidden("You are not authorized to modify this cropland")
}
updatedCropland := &domain.Cropland{
UUID: existingCrop.UUID,
FarmID: existingCrop.FarmID,
Name: input.Body.Name,
Status: input.Body.Status,
Priority: input.Body.Priority,
LandSize: input.Body.LandSize,
GrowthStage: input.Body.GrowthStage,
PlantID: input.Body.PlantID,
GeoFeature: input.Body.GeoFeature,
CreatedAt: existingCrop.CreatedAt,
}
err = a.cropRepo.CreateOrUpdate(ctx, updatedCropland)
if err != nil {
a.logger.Error("Failed to update cropland in database", "croplandId", updatedCropland.UUID, "error", err)
return nil, huma.Error500InternalServerError("Failed to update cropland")
}
a.logger.Info("Cropland updated successfully", "croplandId", updatedCropland.UUID, "farmId", updatedCropland.FarmID)
resp.Body.Cropland = *updatedCropland
return resp, nil
}

View File

@ -68,18 +68,21 @@ func APICmd(ctx context.Context) *cobra.Command {
}() }()
logger.Info("Farm Analytics Projection started") logger.Info("Farm Analytics Projection started")
weatherFetcher := api.GetWeatherFetcher() // Get fetcher instance from API setup apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo)
weatherFetcher := apiInstance.GetWeatherFetcher()
weatherInterval, err := time.ParseDuration(config.WEATHER_FETCH_INTERVAL) weatherInterval, err := time.ParseDuration(config.WEATHER_FETCH_INTERVAL)
if err != nil { if err != nil {
logger.Warn("Invalid WEATHER_FETCH_INTERVAL, using default 15m", "value", config.WEATHER_FETCH_INTERVAL, "error", err) logger.Warn("Invalid WEATHER_FETCH_INTERVAL, using default 15m", "value", config.WEATHER_FETCH_INTERVAL, "error", err)
weatherInterval = 15 * time.Minute weatherInterval = 15 * time.Minute
} }
weatherUpdater := workers.NewWeatherUpdater(farmRepo, weatherFetcher, eventBus, logger, weatherInterval) weatherUpdater, err := workers.NewWeatherUpdater(farmRepo, weatherFetcher, eventBus, logger, weatherInterval)
weatherUpdater.Start(ctx) // Pass the main context if err != nil {
logger.Error("failed to create WeatherUpdater", "error", err)
}
weatherUpdater.Start(ctx)
logger.Info("Weather Updater worker started", "interval", weatherInterval) logger.Info("Weather Updater worker started", "interval", weatherInterval)
apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo) // Pass new repo
server := apiInstance.Server(port) server := apiInstance.Server(port)
serverErrChan := make(chan error, 1) serverErrChan := make(chan error, 1)
@ -87,7 +90,7 @@ func APICmd(ctx context.Context) *cobra.Command {
logger.Info("starting API server", "port", port) logger.Info("starting API server", "port", port)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("API server failed", "error", err) logger.Error("API server failed", "error", err)
serverErrChan <- err // Send error to channel serverErrChan <- err
} }
close(serverErrChan) close(serverErrChan)
}() }()
@ -98,11 +101,10 @@ func APICmd(ctx context.Context) *cobra.Command {
case <-ctx.Done(): case <-ctx.Done():
logger.Info("Shutdown signal received, initiating graceful shutdown...") logger.Info("Shutdown signal received, initiating graceful shutdown...")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) // 15-second grace period shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
weatherUpdater.Stop() // Signal and wait weatherUpdater.Stop()
if err := server.Shutdown(shutdownCtx); err != nil { if err := server.Shutdown(shutdownCtx); err != nil {
logger.Error("HTTP server graceful shutdown failed", "error", err) logger.Error("HTTP server graceful shutdown failed", "error", err)
} else { } else {

View File

@ -20,6 +20,7 @@ var (
OPENWEATHER_API_KEY string OPENWEATHER_API_KEY string
OPENWEATHER_CACHE_TTL string OPENWEATHER_CACHE_TTL string
WEATHER_FETCH_INTERVAL string WEATHER_FETCH_INTERVAL string
GEMINI_API_KEY string
) )
func Load() { func Load() {
@ -36,6 +37,7 @@ func Load() {
viper.SetDefault("OPENWEATHER_API_KEY", "openweather_api_key") viper.SetDefault("OPENWEATHER_API_KEY", "openweather_api_key")
viper.SetDefault("OPENWEATHER_CACHE_TTL", "15m") viper.SetDefault("OPENWEATHER_CACHE_TTL", "15m")
viper.SetDefault("WEATHER_FETCH_INTERVAL", "15m") viper.SetDefault("WEATHER_FETCH_INTERVAL", "15m")
viper.SetDefault("GEMINI_API_KEY", "gemini_api_key")
viper.SetConfigFile(".env") viper.SetConfigFile(".env")
viper.AddConfigPath("../../.") viper.AddConfigPath("../../.")
@ -59,4 +61,5 @@ func Load() {
OPENWEATHER_API_KEY = viper.GetString("OPENWEATHER_API_KEY") OPENWEATHER_API_KEY = viper.GetString("OPENWEATHER_API_KEY")
OPENWEATHER_CACHE_TTL = viper.GetString("OPENWEATHER_CACHE_TTL") OPENWEATHER_CACHE_TTL = viper.GetString("OPENWEATHER_CACHE_TTL")
WEATHER_FETCH_INTERVAL = viper.GetString("WEATHER_FETCH_INTERVAL") WEATHER_FETCH_INTERVAL = viper.GetString("WEATHER_FETCH_INTERVAL")
GEMINI_API_KEY = viper.GetString("GEMINI_API_KEY")
} }

View File

@ -32,6 +32,7 @@ func (f *Farm) Validate() error {
type FarmRepository interface { type FarmRepository interface {
GetByID(context.Context, string) (*Farm, error) GetByID(context.Context, string) (*Farm, error)
GetByOwnerID(context.Context, string) ([]Farm, error) GetByOwnerID(context.Context, string) ([]Farm, error)
GetAll(context.Context) ([]Farm, error)
CreateOrUpdate(context.Context, *Farm) error CreateOrUpdate(context.Context, *Farm) error
Delete(context.Context, string) error Delete(context.Context, string) error
SetEventPublisher(EventPublisher) SetEventPublisher(EventPublisher)

View File

@ -45,6 +45,7 @@ func (p *Plant) Validate() error {
type PlantRepository interface { type PlantRepository interface {
GetByUUID(context.Context, string) (Plant, error) GetByUUID(context.Context, string) (Plant, error)
GetAll(context.Context) ([]Plant, error) GetAll(context.Context) ([]Plant, error)
GetByName(context.Context, string) (Plant, error)
Create(context.Context, *Plant) error Create(context.Context, *Plant) error
Update(context.Context, *Plant) error Update(context.Context, *Plant) error
Delete(context.Context, string) error Delete(context.Context, string) error

View File

@ -1,4 +1,3 @@
// backend/internal/event/projection.go
package event package event
import ( import (
@ -35,11 +34,10 @@ func NewFarmAnalyticsProjection(
func (p *FarmAnalyticsProjection) Start(ctx context.Context) error { func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
eventTypes := []string{ eventTypes := []string{
"farm.created", "farm.updated", "farm.deleted", // Farm lifecycle "farm.created", "farm.updated", "farm.deleted",
"weather.updated", // Weather updates "weather.updated",
"cropland.created", "cropland.updated", "cropland.deleted", // Crop changes trigger count recalc "cropland.created", "cropland.updated", "cropland.deleted",
"inventory.item.created", "inventory.item.updated", "inventory.item.deleted", // Inventory changes trigger timestamp update "inventory.item.created", "inventory.item.updated", "inventory.item.deleted",
// Add other events that might influence FarmAnalytics, e.g., "pest.detected", "yield.recorded"
} }
p.logger.Info("FarmAnalyticsProjection starting, subscribing to events", "types", eventTypes) p.logger.Info("FarmAnalyticsProjection starting, subscribing to events", "types", eventTypes)
@ -49,8 +47,6 @@ func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
if err := p.eventSubscriber.Subscribe(ctx, eventType, p.handleEvent); err != nil { if err := p.eventSubscriber.Subscribe(ctx, eventType, p.handleEvent); err != nil {
p.logger.Error("Failed to subscribe to event type", "type", eventType, "error", err) p.logger.Error("Failed to subscribe to event type", "type", eventType, "error", err)
errs = append(errs, fmt.Errorf("failed to subscribe to %s: %w", eventType, err)) errs = append(errs, fmt.Errorf("failed to subscribe to %s: %w", eventType, err))
// TODO: Decide if we should continue subscribing or fail hard
// return errors.Join(errs...) // Fail hard
} else { } else {
p.logger.Info("Successfully subscribed to event type", "type", eventType) p.logger.Info("Successfully subscribed to event type", "type", eventType)
} }
@ -65,33 +61,30 @@ func (p *FarmAnalyticsProjection) Start(ctx context.Context) error {
} }
func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error { func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) // 10-second timeout ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
p.logger.Debug("Handling event in FarmAnalyticsProjection", "type", event.Type, "aggregate_id", event.AggregateID, "event_id", event.ID) p.logger.Debug("Handling event in FarmAnalyticsProjection", "type", event.Type, "aggregate_id", event.AggregateID, "event_id", event.ID)
farmID := event.AggregateID // Assume AggregateID is the Farm UUID for relevant events farmID := event.AggregateID
// Special case: inventory events might use UserID as AggregateID. // Try to get farmID from payload if AggregateID is empty or potentially not the farmID (e.g., user events)
// Need a way to map UserID to FarmID if necessary, or adjust event publishing. if farmID == "" || event.Type == "inventory.item.created" || event.Type == "inventory.item.updated" || event.Type == "inventory.item.deleted" || event.Type == "cropland.created" || event.Type == "cropland.updated" || event.Type == "cropland.deleted" {
// For now, we assume farmID can be derived or is directly in the payload for inventory events.
if farmID == "" {
payloadMap, ok := event.Payload.(map[string]interface{}) payloadMap, ok := event.Payload.(map[string]interface{})
if ok { if ok {
if idVal, ok := payloadMap["farm_id"].(string); ok && idVal != "" { if idVal, ok := payloadMap["farm_id"].(string); ok && idVal != "" {
farmID = idVal farmID = idVal
} else if idVal, ok := payloadMap["user_id"].(string); ok && idVal != "" { } else if event.Type != "farm.deleted" && event.Type != "farm.created" {
// !! WARNING: Need mapping from user_id to farm_id here !! p.logger.Warn("Could not determine farm_id from event payload or AggregateID", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID)
// This is a temp - requires adding userRepo or similar lookup
p.logger.Warn("Inventory event received without direct farm_id, cannot update stats", "event_id", event.ID, "user_id", idVal)
// Skip inventory stats update if farm_id is missing
return nil return nil
} }
} else if event.Type != "farm.deleted" && event.Type != "farm.created" {
p.logger.Error("Event payload is not a map, cannot extract farm_id", "event_type", event.Type, "event_id", event.ID)
return nil
} }
} }
if farmID == "" && event.Type != "farm.deleted" { // farm.deleted uses AggregateID which is the farmID being deleted if farmID == "" && event.Type != "farm.deleted" {
p.logger.Error("Cannot process event, missing farm_id", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID) p.logger.Error("Cannot process event, missing farm_id", "event_type", event.Type, "event_id", event.ID, "aggregate_id", event.AggregateID)
return nil return nil
} }
@ -99,22 +92,21 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
var err error var err error
switch event.Type { switch event.Type {
case "farm.created", "farm.updated": case "farm.created", "farm.updated":
// Need to get the full Farm domain object from the payload
var farmData domain.Farm var farmData domain.Farm
jsonData, _ := json.Marshal(event.Payload) // Convert payload map back to JSON jsonData, _ := json.Marshal(event.Payload)
if err = json.Unmarshal(jsonData, &farmData); err != nil { if err = json.Unmarshal(jsonData, &farmData); err != nil {
p.logger.Error("Failed to unmarshal farm data from event payload", "event_id", event.ID, "error", err) p.logger.Error("Failed to unmarshal farm data from event payload", "event_id", event.ID, "error", err)
// Nack or Ack based on error strategy? Ack for now.
return nil return nil
} }
// Ensure UUID is set from AggregateID if missing in payload itself
if farmData.UUID == "" { if farmData.UUID == "" {
farmData.UUID = event.AggregateID farmData.UUID = event.AggregateID
} }
p.logger.Info("Processing farm event", "event_type", event.Type, "farm_id", farmData.UUID, "owner_id", farmData.OwnerID)
err = p.repository.CreateOrUpdateFarmBaseData(ctx, &farmData) err = p.repository.CreateOrUpdateFarmBaseData(ctx, &farmData)
case "farm.deleted": case "farm.deleted":
farmID = event.AggregateID // Use AggregateID directly for delete farmID = event.AggregateID
if farmID == "" { if farmID == "" {
p.logger.Error("Cannot process farm.deleted event, missing farm_id in AggregateID", "event_id", event.ID) p.logger.Error("Cannot process farm.deleted event, missing farm_id in AggregateID", "event_id", event.ID)
return nil return nil
@ -122,12 +114,11 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
err = p.repository.DeleteFarmAnalytics(ctx, farmID) err = p.repository.DeleteFarmAnalytics(ctx, farmID)
case "weather.updated": case "weather.updated":
// Extract weather data from payload
var weatherData domain.WeatherData var weatherData domain.WeatherData
jsonData, _ := json.Marshal(event.Payload) jsonData, _ := json.Marshal(event.Payload)
if err = json.Unmarshal(jsonData, &weatherData); err != nil { if err = json.Unmarshal(jsonData, &weatherData); err != nil {
p.logger.Error("Failed to unmarshal weather data from event payload", "event_id", event.ID, "error", err) p.logger.Error("Failed to unmarshal weather data from event payload", "event_id", event.ID, "error", err)
return nil // Acknowledge bad data return nil
} }
err = p.repository.UpdateFarmAnalyticsWeather(ctx, farmID, &weatherData) err = p.repository.UpdateFarmAnalyticsWeather(ctx, farmID, &weatherData)
@ -146,8 +137,6 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
err = p.repository.UpdateFarmAnalyticsCropStats(ctx, farmID) err = p.repository.UpdateFarmAnalyticsCropStats(ctx, farmID)
case "inventory.item.created", "inventory.item.updated", "inventory.item.deleted": case "inventory.item.created", "inventory.item.updated", "inventory.item.deleted":
// farmID needs to be looked up or present in payload
// For now, we only touch the timestamp
if farmID != "" { if farmID != "" {
err = p.repository.UpdateFarmAnalyticsInventoryStats(ctx, farmID) err = p.repository.UpdateFarmAnalyticsInventoryStats(ctx, farmID)
} else { } else {
@ -162,7 +151,6 @@ func (p *FarmAnalyticsProjection) handleEvent(event domain.Event) error {
if err != nil { if err != nil {
p.logger.Error("Failed to update farm analytics", "event_type", event.Type, "farm_id", farmID, "error", err) p.logger.Error("Failed to update farm analytics", "event_type", event.Type, "farm_id", farmID, "error", err)
// Decide whether to return the error (potentially causing requeue) or nil (ack)
return nil return nil
} }

View File

@ -124,20 +124,19 @@ func (p *postgresCroplandRepository) CreateOrUpdate(ctx context.Context, c *doma
if c.GeoFeature != nil { if c.GeoFeature != nil {
_ = json.Unmarshal(c.GeoFeature, &geoFeatureMap) _ = json.Unmarshal(c.GeoFeature, &geoFeatureMap)
} }
payload := map[string]interface{}{ payload := map[string]interface{}{
"crop_id": c.UUID, "uuid": c.UUID,
"name": c.Name, "name": c.Name,
"status": c.Status, "status": c.Status,
"priority": c.Priority, "priority": c.Priority,
"land_size": c.LandSize, "landSize": c.LandSize,
"growth_stage": c.GrowthStage, "growthStage": c.GrowthStage,
"plant_id": c.PlantID, "plantId": c.PlantID,
"farm_id": c.FarmID, "farmId": c.FarmID,
"geo_feature": geoFeatureMap, "geoFeature": geoFeatureMap,
"created_at": c.CreatedAt, "createdAt": c.CreatedAt,
"updated_at": c.UpdatedAt, "updatedAt": c.UpdatedAt,
"event_type": eventType, "event_type": eventType,
} }
event := domain.Event{ event := domain.Event{

View File

@ -2,11 +2,13 @@ package repository
import ( import (
"context" "context"
"errors"
"strings" "strings"
"time" "time"
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
) )
type postgresFarmRepository struct { type postgresFarmRepository struct {
@ -94,11 +96,52 @@ func (p *postgresFarmRepository) fetchCroplandsByFarmIDs(ctx context.Context, fa
return croplandsByFarmID, nil return croplandsByFarmID, nil
} }
func (p *postgresFarmRepository) GetAll(ctx context.Context) ([]domain.Farm, error) {
// Query to select all farms, ordered by creation date for consistency
query := `
SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
FROM farms
ORDER BY created_at DESC`
// Use the existing fetch method without specific arguments for filtering
farms, err := p.fetch(ctx, query)
if err != nil {
return nil, err
}
if len(farms) == 0 {
return []domain.Farm{}, nil // Return empty slice, not nil
}
// --- Fetch associated crops (optional but good for consistency) ---
farmIDs := make([]string, 0, len(farms))
farmMap := make(map[string]*domain.Farm, len(farms))
for i := range farms {
farmIDs = append(farmIDs, farms[i].UUID)
farmMap[farms[i].UUID] = &farms[i]
}
croplandsByFarmID, err := p.fetchCroplandsByFarmIDs(ctx, farmIDs)
if err != nil {
// Log the warning but return the farms fetched so far
// Depending on requirements, you might want to return the error instead
println("Warning: Failed to fetch associated croplands during GetAll:", err.Error())
} else {
for farmID, croplands := range croplandsByFarmID {
if farm, ok := farmMap[farmID]; ok {
farm.Crops = croplands
}
}
}
// --- End Fetch associated crops ---
return farms, nil
}
func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*domain.Farm, error) { func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*domain.Farm, error) {
query := ` query := `
SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id SELECT uuid, name, lat, lon, farm_type, total_size, created_at, updated_at, owner_id
FROM farms FROM farms
WHERE uuid = $1` WHERE uuid = $1`
var f domain.Farm var f domain.Farm
err := p.conn.QueryRow(ctx, query, farmId).Scan( err := p.conn.QueryRow(ctx, query, farmId).Scan(
&f.UUID, &f.UUID,
@ -112,8 +155,21 @@ func (p *postgresFarmRepository) GetByID(ctx context.Context, farmId string) (*d
&f.OwnerID, &f.OwnerID,
) )
if err != nil { if err != nil {
return nil, err if errors.Is(err, pgx.ErrNoRows) { // Check for pgx specific error
return nil, domain.ErrNotFound
}
return nil, err // Return other errors
} }
// Fetch associated crops (optional, depends if GetByID needs them)
cropsMap, err := p.fetchCroplandsByFarmIDs(ctx, []string{f.UUID})
if err != nil {
println("Warning: Failed to fetch croplands for GetByID:", err.Error())
// Decide whether to return the farm without crops or return the error
} else if crops, ok := cropsMap[f.UUID]; ok {
f.Crops = crops
}
return &f, nil return &f, nil
} }
@ -192,14 +248,16 @@ func (p *postgresFarmRepository) CreateOrUpdate(ctx context.Context, f *domain.F
Timestamp: time.Now(), Timestamp: time.Now(),
AggregateID: f.UUID, AggregateID: f.UUID,
Payload: map[string]interface{}{ Payload: map[string]interface{}{
"farm_id": f.UUID, "uuid": f.UUID,
"name": f.Name, "name": f.Name,
"location": map[string]float64{"lat": f.Lat, "lon": f.Lon}, "lat": f.Lat,
"farm_type": f.FarmType, "lon": f.Lon,
"total_size": f.TotalSize, "location": map[string]float64{"lat": f.Lat, "lon": f.Lon},
"owner_id": f.OwnerID, "farmType": f.FarmType,
"created_at": f.CreatedAt, "totalSize": f.TotalSize,
"updated_at": f.UpdatedAt, "ownerId": f.OwnerID,
"createdAt": f.CreatedAt,
"updatedAt": f.UpdatedAt,
}, },
} }

View File

@ -59,7 +59,7 @@ func (r *postgresFarmAnalyticsRepository) GetFarmAnalytics(ctx context.Context,
&analytics.OwnerID, &analytics.OwnerID,
&farmType, &farmType,
&totalSize, &totalSize,
&analytics.Latitude, // Scan directly into the struct fields &analytics.Latitude,
&analytics.Longitude, &analytics.Longitude,
&weatherJSON, &weatherJSON,
&inventoryJSON, &inventoryJSON,
@ -228,16 +228,16 @@ func (r *postgresFarmAnalyticsRepository) GetCropAnalytics(ctx context.Context,
func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error { func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context.Context, farm *domain.Farm) error {
query := ` query := `
INSERT INTO farm_analytics (farm_id, farm_name, owner_id, farm_type, total_size, lat, lon, last_updated) INSERT INTO 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, $8) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (farm_id) DO UPDATE ON CONFLICT (farm_id) DO UPDATE
SET farm_name = EXCLUDED.farm_name, 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,
lat = EXCLUDED.lat, latitude = EXCLUDED.latitude,
lon = EXCLUDED.lon, longitude = EXCLUDED.longitude,
last_updated = EXCLUDED.last_updated;` analytics_last_updated = EXCLUDED.analytics_last_updated;`
_, err := r.conn.Exec(ctx, query, _, err := r.conn.Exec(ctx, query,
farm.UUID, farm.UUID,
@ -259,158 +259,100 @@ func (r *postgresFarmAnalyticsRepository) CreateOrUpdateFarmBaseData(ctx context
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 fmt.Errorf("weather data cannot be nil") return errors.New("weather data cannot be nil for update")
} }
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 := ` query := `
UPDATE farm_analytics UPDATE public.farm_analytics SET
SET weather_data = $1, weather_temp_celsius = $2,
last_updated = $2 weather_humidity = $3,
WHERE farm_id = $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`
cmdTag, err := r.conn.Exec(ctx, query, weatherJSON, time.Now().UTC(), farmID) _, err := r.conn.Exec(ctx, query,
farmID,
weatherData.TempCelsius,
weatherData.Humidity,
weatherData.Description,
weatherData.Icon,
weatherData.WindSpeed,
weatherData.RainVolume1h,
weatherData.ObservedAt,
)
if err != nil { if err != nil {
r.logger.Error("Failed to update farm analytics weather data", "farm_id", farmID, "error", err) r.logger.Error("Error updating farm weather analytics", "farm_id", farmID, "error", err)
return fmt.Errorf("database update failed for weather data: %w", err) return fmt.Errorf("failed to update weather analytics for farm %s: %w", farmID, err)
} }
if cmdTag.RowsAffected() == 0 { r.logger.Debug("Updated farm weather analytics", "farm_id", farmID)
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
} }
// UpdateFarmAnalyticsCropStats needs to query the croplands table for the farm // UpdateFarmAnalyticsCropStats needs to query the croplands table for the farm
func (r *postgresFarmAnalyticsRepository) UpdateFarmAnalyticsCropStats(ctx context.Context, farmID string) error { 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
`
var totalCount, growingCount int var totalCount, growingCount int
err := r.conn.QueryRow(ctx, countQuery, farmID).Scan(&totalCount, &growingCount)
// 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 { if err != nil {
// Log error but don't fail the projection if stats can't be calculated temporarily if !errors.Is(err, pgx.ErrNoRows) {
r.logger.Error("Failed to calculate crop stats for analytics", "farm_id", farmID, "error", err) r.logger.Error("Error calculating crop counts", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to calculate crop stats: %w", err) return fmt.Errorf("failed to calculate crop stats for farm %s: %w", farmID, 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 farm_analytics UPDATE public.farm_analytics SET
SET crop_data = $1, crop_total_count = $2,
last_updated = $2 -- Also update the main last_updated timestamp crop_growing_count = $3,
WHERE farm_id = $3;` crop_last_updated = NOW(),
analytics_last_updated = NOW()
WHERE farm_id = $1`
cmdTag, err := r.conn.Exec(ctx, updateQuery, cropJSON, time.Now().UTC(), farmID) cmdTag, err := r.conn.Exec(ctx, updateQuery, farmID, totalCount, growingCount)
if err != nil { if err != nil {
r.logger.Error("Failed to update farm analytics crop stats", "farm_id", farmID, "error", err) r.logger.Error("Error updating farm crop stats", "farm_id", farmID, "error", err)
return fmt.Errorf("database update failed for crop stats: %w", err) return fmt.Errorf("failed to update crop stats for farm %s: %w", farmID, 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 // Optionally, create the base record here if it should always exist
} else { return r.CreateOrUpdateFarmBaseData(ctx, &domain.Farm{UUID: farmID /* Fetch other details */})
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
} }
// UpdateFarmAnalyticsInventoryStats needs to query inventory_items // 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 {
var totalItems, lowStockCount int
var lastUpdated sql.NullTime
// Query to get inventory stats for the user owning the farm
// NOTE: This assumes inventory is linked by user_id, and we need the user_id for the farm owner.
// Step 1: Get Owner ID from farm_analytics table
var ownerID string
ownerQuery := `SELECT owner_id FROM farm_analytics WHERE farm_id = $1`
err := r.conn.QueryRow(ctx, ownerQuery, farmID).Scan(&ownerID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) || errors.Is(err, pgx.ErrNoRows) {
r.logger.Warn("Cannot update inventory stats, farm analytics record not found", "farm_id", farmID)
return nil // Or return ErrNotFound if critical
}
r.logger.Error("Failed to get owner ID for inventory stats update", "farm_id", farmID, "error", err)
return fmt.Errorf("failed to get owner ID: %w", err)
}
// Step 2: Query inventory based on owner ID
query := ` query := `
SELECT UPDATE public.farm_analytics SET
COUNT(*), -- inventory_total_items = (SELECT COUNT(*) FROM ... WHERE farm_id = $1), -- Example future logic
COUNT(*) FILTER (WHERE status_id = (SELECT id FROM inventory_status WHERE name = 'Low Stock')), -- Assumes 'Low Stock' status name -- inventory_low_stock_count = (SELECT COUNT(*) FROM ... WHERE farm_id = $1 AND status = 'low'), -- Example
MAX(updated_at) -- Get the latest update timestamp from inventory items inventory_last_updated = NOW(),
FROM inventory_items analytics_last_updated = NOW()
WHERE user_id = $1;` WHERE farm_id = $1`
err = r.conn.QueryRow(ctx, query, ownerID).Scan(&totalItems, &lowStockCount, &lastUpdated) cmdTag, err := r.conn.Exec(ctx, query, farmID)
if err != nil { if err != nil {
// Log error but don't fail the projection if stats can't be calculated temporarily r.logger.Error("Error touching inventory timestamp in farm analytics", "farm_id", farmID, "error", err)
r.logger.Error("Failed to calculate inventory stats for analytics", "farm_id", farmID, "owner_id", ownerID, "error", err) return fmt.Errorf("failed to update inventory stats timestamp for farm %s: %w", farmID, 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 stats", "farm_id", farmID) r.logger.Warn("No farm analytics record found to update inventory timestamp", "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
} }
@ -432,20 +374,18 @@ func (r *postgresFarmAnalyticsRepository) DeleteFarmAnalytics(ctx context.Contex
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 farm_analytics UPDATE public.farm_analytics SET
SET overall_status = $1, overall_status = $2,
last_updated = $2 analytics_last_updated = NOW()
WHERE farm_id = $3;` WHERE farm_id = $1`
cmdTag, err := r.conn.Exec(ctx, query, status, time.Now().UTC(), farmID) cmdTag, err := r.conn.Exec(ctx, query, farmID, status)
if err != nil { if err != nil {
r.logger.Error("Failed to update farm overall status", "farm_id", farmID, "status", status, "error", err) r.logger.Error("Error updating farm overall status", "farm_id", farmID, "status", status, "error", err)
return fmt.Errorf("database update failed for overall status: %w", err) return fmt.Errorf("failed to update overall status for farm %s: %w", farmID, 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

@ -268,15 +268,15 @@ func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *
} }
payload := map[string]interface{}{ payload := map[string]interface{}{
"item_id": item.ID, "id": item.ID,
"user_id": item.UserID, // Include user ID for potential farm lookup in projection "userId": item.UserID, // Include user ID for potential farm lookup in projection
"name": item.Name, "name": item.Name,
"category_id": item.CategoryID, "categoryId": item.CategoryID,
"quantity": item.Quantity, "quantity": item.Quantity,
"unit_id": item.UnitID, "unitId": item.UnitID,
"status_id": item.StatusID, "statusId": item.StatusID,
"date_added": item.DateAdded, "dateAdded": item.DateAdded,
"updated_at": item.UpdatedAt, "updatedAt": item.UpdatedAt,
// NO farm_id easily available here without extra lookup // NO farm_id easily available here without extra lookup
} }

View File

@ -51,6 +51,15 @@ func (p *postgresPlantRepository) GetByUUID(ctx context.Context, uuid string) (d
return plants[0], nil return plants[0], nil
} }
func (p *postgresPlantRepository) GetByName(ctx context.Context, name string) (domain.Plant, error) {
query := `SELECT * FROM plants WHERE name = $1`
plants, err := p.fetch(ctx, query, name)
if err != nil || len(plants) == 0 {
return domain.Plant{}, domain.ErrNotFound
}
return plants[0], nil
}
func (p *postgresPlantRepository) GetAll(ctx context.Context) ([]domain.Plant, error) { func (p *postgresPlantRepository) GetAll(ctx context.Context) ([]domain.Plant, error) {
query := `SELECT * FROM plants` query := `SELECT * FROM plants`
return p.fetch(ctx, query) return p.fetch(ctx, query)

View File

@ -7,41 +7,31 @@ import (
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
) )
// AnalyticsService provides methods for calculating or deriving analytics data.
// For now, it contains dummy implementations.
type AnalyticsService struct { type AnalyticsService struct {
// Add dependencies like repositories if needed for real logic later
} }
// NewAnalyticsService creates a new AnalyticsService.
func NewAnalyticsService() *AnalyticsService { func NewAnalyticsService() *AnalyticsService {
return &AnalyticsService{} return &AnalyticsService{}
} }
// CalculatePlantHealth provides a dummy health status.
// TODO: Implement real health calculation based on status, weather, events, etc.
func (s *AnalyticsService) CalculatePlantHealth(status string, growthStage string) string { func (s *AnalyticsService) CalculatePlantHealth(status string, growthStage string) string {
// Simple dummy logic
switch status { switch status {
case "Problem", "Diseased", "Infested": case "Problem", "Diseased", "Infested":
return "warning" return "warning"
case "Fallow", "Harvested": case "Fallow", "Harvested":
return "n/a" // Or maybe 'good' if fallow is considered healthy state return "n/a"
default: default:
// Slightly randomize for demo purposes // 20% chance of warning even if status is 'growing'
if rand.Intn(10) < 2 { // 20% chance of warning even if status is 'growing' if rand.Intn(10) < 2 {
return "warning" return "warning"
} }
return "good" return "good"
} }
} }
// SuggestNextAction provides a dummy next action based on growth stage.
// TODO: Implement real suggestion logic based on stage, weather, history, plant type etc.
func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated time.Time) (action *string, dueDate *time.Time) { func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated time.Time) (action *string, dueDate *time.Time) {
// Default action
nextActionStr := "Monitor crop health" nextActionStr := "Monitor crop health"
nextDueDate := time.Now().Add(24 * time.Hour) // Check tomorrow nextDueDate := time.Now().Add(24 * time.Hour)
switch growthStage { switch growthStage {
case "Planned", "Planting": case "Planned", "Planting":
@ -58,30 +48,27 @@ func (s *AnalyticsService) SuggestNextAction(growthStage string, lastUpdated tim
nextDueDate = time.Now().Add(48 * time.Hour) nextDueDate = time.Now().Add(48 * time.Hour)
case "Fruiting", "Ripening": case "Fruiting", "Ripening":
nextActionStr = "Monitor fruit development and prepare for harvest" nextActionStr = "Monitor fruit development and prepare for harvest"
nextDueDate = time.Now().Add(7 * 24 * time.Hour) // Check in a week nextDueDate = time.Now().Add(7 * 24 * time.Hour)
case "Harvesting": case "Harvesting":
nextActionStr = "Proceed with harvest" nextActionStr = "Proceed with harvest"
nextDueDate = time.Now().Add(24 * time.Hour) nextDueDate = time.Now().Add(24 * time.Hour)
} }
// Only return if the suggestion is "newer" than the last update to avoid constant same suggestion // Only suggest if due date is >1hr after last update
// This is basic logic, real implementation would be more complex if nextDueDate.After(lastUpdated.Add(1 * time.Hour)) {
if nextDueDate.After(lastUpdated.Add(1 * time.Hour)) { // Only suggest if due date is >1hr after last update
return &nextActionStr, &nextDueDate return &nextActionStr, &nextDueDate
} }
return nil, nil // No immediate action needed or suggestion is old return nil, nil
} }
// GetNutrientLevels provides dummy nutrient levels.
// TODO: Implement real nutrient level fetching (e.g., from soil sensors, lab results events).
func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct { func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct {
Nitrogen *float64 `json:"nitrogen,omitempty"` Nitrogen *float64 `json:"nitrogen,omitempty"`
Phosphorus *float64 `json:"phosphorus,omitempty"` Phosphorus *float64 `json:"phosphorus,omitempty"`
Potassium *float64 `json:"potassium,omitempty"` Potassium *float64 `json:"potassium,omitempty"`
} { } {
// Return dummy data or nil if unavailable // 70% chance of having dummy data
if rand.Intn(10) < 7 { // 70% chance of having dummy data if rand.Intn(10) < 7 {
n := float64(50 + rand.Intn(40)) // 50-89 n := float64(50 + rand.Intn(40)) // 50-89
p := float64(40 + rand.Intn(40)) // 40-79 p := float64(40 + rand.Intn(40)) // 40-79
k := float64(45 + rand.Intn(40)) // 45-84 k := float64(45 + rand.Intn(40)) // 45-84
@ -95,26 +82,20 @@ func (s *AnalyticsService) GetNutrientLevels(cropID string) *struct {
Potassium: &k, Potassium: &k,
} }
} }
return nil // Simulate data not available return nil
} }
// GetEnvironmentalData attempts to retrieve relevant environmental data.
// TODO: Enhance this - Could query specific weather events for the crop location/timeframe.
// Currently relies on potentially stale FarmAnalytics weather.
func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalytics) (temp, humidity, wind, rain, sunlight, soilMoisture *float64) { func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalytics) (temp, humidity, wind, rain, sunlight, soilMoisture *float64) {
// Initialize with nil
temp, humidity, wind, rain, sunlight, soilMoisture = nil, nil, nil, nil, nil, nil temp, humidity, wind, rain, sunlight, soilMoisture = nil, nil, nil, nil, nil, nil
// Try to get from FarmAnalytics
if farmAnalytics != nil && farmAnalytics.Weather != nil { if farmAnalytics != nil && farmAnalytics.Weather != nil {
temp = farmAnalytics.Weather.TempCelsius temp = farmAnalytics.Weather.TempCelsius
humidity = farmAnalytics.Weather.Humidity humidity = farmAnalytics.Weather.Humidity
wind = farmAnalytics.Weather.WindSpeed wind = farmAnalytics.Weather.WindSpeed
rain = farmAnalytics.Weather.RainVolume1h rain = farmAnalytics.Weather.RainVolume1h
// Note: Sunlight and SoilMoisture are not typically in basic WeatherData
} }
// Provide dummy values ONLY if still nil (ensures real data isn't overwritten) // Provide dummy values only if data is missing
if temp == nil { if temp == nil {
t := float64(18 + rand.Intn(15)) // 18-32 C t := float64(18 + rand.Intn(15)) // 18-32 C
temp = &t temp = &t
@ -128,7 +109,6 @@ func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalyt
wind = &w wind = &w
} }
if rain == nil { if rain == nil {
// Simulate less frequent rain
r := 0.0 r := 0.0
if rand.Intn(10) < 2 { // 20% chance of rain if rand.Intn(10) < 2 { // 20% chance of rain
r = float64(rand.Intn(5)) // 0-4 mm r = float64(rand.Intn(5)) // 0-4 mm
@ -144,5 +124,5 @@ func (s *AnalyticsService) GetEnvironmentalData(farmAnalytics *domain.FarmAnalyt
soilMoisture = &sm soilMoisture = &sm
} }
return // Named return values return
} }

View File

@ -0,0 +1,445 @@
package services
import (
"context"
"errors"
"fmt"
"log/slog"
"math/rand"
"strings"
"time"
"github.com/forfarm/backend/internal/config"
"github.com/forfarm/backend/internal/domain"
"github.com/google/generative-ai-go/genai"
"google.golang.org/api/option"
)
type ChatService struct {
client *genai.Client
logger *slog.Logger
analyticsRepo domain.AnalyticsRepository
farmRepo domain.FarmRepository
cropRepo domain.CroplandRepository
inventoryRepo domain.InventoryRepository
plantRepo domain.PlantRepository
}
func NewChatService(
logger *slog.Logger,
analyticsRepo domain.AnalyticsRepository,
farmRepo domain.FarmRepository,
cropRepo domain.CroplandRepository,
inventoryRepo domain.InventoryRepository,
plantRepo domain.PlantRepository,
) (*ChatService, error) {
if config.GEMINI_API_KEY == "" {
logger.Warn("GEMINI_API_KEY not set, ChatService will not function.")
return &ChatService{client: nil, logger: logger}, nil
}
ctx := context.Background()
client, err := genai.NewClient(ctx, option.WithAPIKey(config.GEMINI_API_KEY))
if err != nil {
logger.Error("Failed to create Gemini client", "error", err)
return nil, fmt.Errorf("error creating genai client: %w", err)
}
logger.Info("Gemini client initialized successfully")
return &ChatService{
client: client,
logger: logger,
analyticsRepo: analyticsRepo,
farmRepo: farmRepo,
cropRepo: cropRepo,
inventoryRepo: inventoryRepo,
plantRepo: plantRepo,
}, nil
}
type GenerateResponseInput struct {
UserID string
Message string
FarmID string
CropID string
History []*genai.Content
}
// --- Context Building Helpers ---
func (s *ChatService) buildCropContextString(ctx context.Context, cropID, userID string) (string, error) {
var contextBuilder strings.Builder
contextBuilder.WriteString("## Current Crop & Plant Context ##\n")
cropAnalytics, err := s.analyticsRepo.GetCropAnalytics(ctx, cropID)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
s.logger.Warn("Crop analytics not found for context", "cropId", cropID)
return "", fmt.Errorf("crop not found")
}
s.logger.Error("Failed to fetch crop analytics context", "cropId", cropID, "error", err)
contextBuilder.WriteString(fmt.Sprintf("Error fetching crop details for ID %s.\n", cropID))
return "", fmt.Errorf("failed to fetch crop details")
}
farm, err := s.farmRepo.GetByID(ctx, cropAnalytics.FarmID)
if err != nil || farm.OwnerID != userID {
s.logger.Warn("Ownership check failed for crop context", "cropId", cropID, "farmId", cropAnalytics.FarmID, "userId", userID)
return "", fmt.Errorf("unauthorized access to crop data")
}
fmt.Fprintf(&contextBuilder, "Crop Name: %s (ID: %s)\n", cropAnalytics.CropName, cropAnalytics.CropID)
fmt.Fprintf(&contextBuilder, "Farm: %s (ID: %s)\n", farm.Name, cropAnalytics.FarmID)
fmt.Fprintf(&contextBuilder, "Plant: %s (Variety: %s)\n", cropAnalytics.PlantName, safeString(cropAnalytics.Variety))
fmt.Fprintf(&contextBuilder, "Status: %s\n", cropAnalytics.CurrentStatus)
fmt.Fprintf(&contextBuilder, "Growth Stage: %s\n", cropAnalytics.GrowthStage)
fmt.Fprintf(&contextBuilder, "Land Size: %.2f ha\n", cropAnalytics.LandSize)
fmt.Fprintf(&contextBuilder, "Growth Progress: %d%%\n", cropAnalytics.GrowthProgress)
if cropAnalytics.PlantHealth != nil {
fmt.Fprintf(&contextBuilder, "Health Status: %s\n", *cropAnalytics.PlantHealth)
}
if cropAnalytics.Temperature != nil {
fmt.Fprintf(&contextBuilder, "Temperature: %.1f°C\n", *cropAnalytics.Temperature)
}
if cropAnalytics.Humidity != nil {
fmt.Fprintf(&contextBuilder, "Humidity: %.0f%%\n", *cropAnalytics.Humidity)
}
if cropAnalytics.SoilMoisture != nil {
fmt.Fprintf(&contextBuilder, "Soil Moisture: %.0f%%\n", *cropAnalytics.SoilMoisture)
}
if cropAnalytics.Rainfall != nil {
fmt.Fprintf(&contextBuilder, "Rainfall (1h): %.1f mm\n", *cropAnalytics.Rainfall)
}
if cropAnalytics.WindSpeed != nil {
fmt.Fprintf(&contextBuilder, "Wind Speed: %.1f m/s\n", *cropAnalytics.WindSpeed)
}
if cropAnalytics.Sunlight != nil {
fmt.Fprintf(&contextBuilder, "Sunlight Exposure: %.0f%%\n", *cropAnalytics.Sunlight)
}
if cropAnalytics.NutrientLevels != nil {
contextBuilder.WriteString("Nutrients: ")
nutrients := []string{}
if cropAnalytics.NutrientLevels.Nitrogen != nil {
nutrients = append(nutrients, fmt.Sprintf("N=%.0f%%", *cropAnalytics.NutrientLevels.Nitrogen))
}
if cropAnalytics.NutrientLevels.Phosphorus != nil {
nutrients = append(nutrients, fmt.Sprintf("P=%.0f%%", *cropAnalytics.NutrientLevels.Phosphorus))
}
if cropAnalytics.NutrientLevels.Potassium != nil {
nutrients = append(nutrients, fmt.Sprintf("K=%.0f%%", *cropAnalytics.NutrientLevels.Potassium))
}
if len(nutrients) > 0 {
contextBuilder.WriteString(strings.Join(nutrients, ", "))
} else {
contextBuilder.WriteString("Not Available")
}
contextBuilder.WriteString("\n")
}
if cropAnalytics.NextAction != nil {
dueStr := ""
if cropAnalytics.NextActionDue != nil {
dueStr = fmt.Sprintf(" (Due: %s)", cropAnalytics.NextActionDue.Format(time.RFC1123))
}
fmt.Fprintf(&contextBuilder, "Suggested Next Action: %s%s\n", *cropAnalytics.NextAction, dueStr)
}
contextBuilder.WriteString("\n")
contextBuilder.WriteString("Plant Details:\n")
plant, err := s.plantRepo.GetByName(ctx, cropAnalytics.PlantName)
if err != nil {
s.logger.Warn("Could not fetch plant details for context", "plantId", cropAnalytics.PlantName, "error", err)
fmt.Fprintf(&contextBuilder, " - Could not retrieve plant details.\n")
} else {
fmt.Fprintf(&contextBuilder, " - Type: %s (Variety: %s)\n", plant.Name, safeString(plant.Variety))
if plant.DaysToMaturity != nil {
fmt.Fprintf(&contextBuilder, " - Days to Maturity: ~%d\n", *plant.DaysToMaturity)
}
if plant.OptimalTemp != nil {
fmt.Fprintf(&contextBuilder, " - Optimal Temp: %.1f°C\n", *plant.OptimalTemp)
}
if plant.WaterNeeds != nil {
fmt.Fprintf(&contextBuilder, " - Water Needs: %.1f (units unspecified)\n", *plant.WaterNeeds)
}
if plant.PHValue != nil {
fmt.Fprintf(&contextBuilder, " - Soil pH: %.1f\n", *plant.PHValue)
}
if plant.RowSpacing != nil {
fmt.Fprintf(&contextBuilder, " - Row Spacing: %.1f (units unspecified)\n", *plant.RowSpacing)
}
if plant.PlantingDepth != nil {
fmt.Fprintf(&contextBuilder, " - Planting Depth: %.1f (units unspecified)\n", *plant.PlantingDepth)
}
}
contextBuilder.WriteString("\n")
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
contextBuilder.WriteString(inventoryContext)
return contextBuilder.String(), nil
}
func (s *ChatService) buildFarmContextString(ctx context.Context, farmID, userID string) (string, error) {
var contextBuilder strings.Builder
contextBuilder.WriteString("## Current Farm Context ##\n")
farmAnalytics, err := s.analyticsRepo.GetFarmAnalytics(ctx, farmID)
if err != nil || farmAnalytics.OwnerID != userID {
if errors.Is(err, domain.ErrNotFound) || farmAnalytics == nil || farmAnalytics.OwnerID != userID {
s.logger.Warn("Farm analytics not found or ownership mismatch for context", "farmId", farmID, "userId", userID)
return "", fmt.Errorf("farm not found or access denied")
}
s.logger.Error("Failed to fetch farm analytics context", "farmId", farmID, "error", err)
return "", fmt.Errorf("failed to fetch farm details")
}
fmt.Fprintf(&contextBuilder, "Farm Name: %s (ID: %s)\n", farmAnalytics.FarmName, farmAnalytics.FarmID)
if farmAnalytics.FarmType != nil {
fmt.Fprintf(&contextBuilder, "Type: %s\n", *farmAnalytics.FarmType)
}
if farmAnalytics.TotalSize != nil {
fmt.Fprintf(&contextBuilder, "Size: %s\n", *farmAnalytics.TotalSize)
}
fmt.Fprintf(&contextBuilder, "Location: Lat %.4f, Lon %.4f\n", farmAnalytics.Latitude, farmAnalytics.Longitude)
if farmAnalytics.OverallStatus != nil {
fmt.Fprintf(&contextBuilder, "Overall Status: %s\n", *farmAnalytics.OverallStatus)
}
if farmAnalytics.Weather != nil {
contextBuilder.WriteString("Weather:\n")
if farmAnalytics.Weather.TempCelsius != nil {
fmt.Fprintf(&contextBuilder, " - Temp: %.1f°C\n", *farmAnalytics.Weather.TempCelsius)
}
if farmAnalytics.Weather.Humidity != nil {
fmt.Fprintf(&contextBuilder, " - Humidity: %.0f%%\n", *farmAnalytics.Weather.Humidity)
}
if farmAnalytics.Weather.Description != nil {
fmt.Fprintf(&contextBuilder, " - Condition: %s\n", *farmAnalytics.Weather.Description)
}
if farmAnalytics.Weather.WindSpeed != nil {
fmt.Fprintf(&contextBuilder, " - Wind: %.1f m/s\n", *farmAnalytics.Weather.WindSpeed)
}
if farmAnalytics.Weather.RainVolume1h != nil {
fmt.Fprintf(&contextBuilder, " - Rain (1h): %.1f mm\n", *farmAnalytics.Weather.RainVolume1h)
}
if farmAnalytics.Weather.WeatherLastUpdated != nil {
fmt.Fprintf(&contextBuilder, " - Last Updated: %s\n", farmAnalytics.Weather.WeatherLastUpdated.Format(time.RFC1123))
}
}
crops, err := s.cropRepo.GetByFarmID(ctx, farmID)
if err == nil && len(crops) > 0 {
contextBuilder.WriteString("Crops on Farm:\n")
for i, crop := range crops {
if i >= 5 {
fmt.Fprintf(&contextBuilder, " - ... and %d more\n", len(crops)-5)
break
}
fmt.Fprintf(&contextBuilder, " - %s (Status: %s, Stage: %s)\n", crop.Name, crop.Status, crop.GrowthStage)
}
} else if err != nil {
s.logger.Warn("Failed to fetch crops for farm context", "farmId", farmID, "error", err)
}
contextBuilder.WriteString("\n")
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
contextBuilder.WriteString(inventoryContext)
return contextBuilder.String(), nil
}
func (s *ChatService) buildInventoryContextString(ctx context.Context, userID string) (string, error) {
var contextBuilder strings.Builder
contextBuilder.WriteString("## Inventory Summary ##\n")
filter := domain.InventoryFilter{UserID: userID}
sort := domain.InventorySort{Field: "name", Direction: "asc"}
items, err := s.inventoryRepo.GetByUserID(ctx, userID, filter, sort)
if err != nil {
s.logger.Warn("Failed to fetch inventory for context", "userId", userID, "error", err)
fmt.Fprintf(&contextBuilder, "Could not retrieve inventory details.\n")
return contextBuilder.String(), err
}
if len(items) == 0 {
fmt.Fprintf(&contextBuilder, "No inventory items found.\n")
return contextBuilder.String(), nil
}
lowStockCount := 0
fmt.Fprintf(&contextBuilder, "Items (%d total):\n", len(items))
limit := 10
for i, item := range items {
if i >= limit {
fmt.Fprintf(&contextBuilder, "- ... and %d more\n", len(items)-limit)
break
}
statusName := item.Status.Name
if statusName == "" && item.StatusID != 0 {
statusName = fmt.Sprintf("StatusID %d", item.StatusID)
}
unitName := item.Unit.Name
if unitName == "" && item.UnitID != 0 {
unitName = fmt.Sprintf("UnitID %d", item.UnitID)
}
fmt.Fprintf(&contextBuilder, "- %s: %.2f %s (Status: %s)\n", item.Name, item.Quantity, unitName, statusName)
if strings.Contains(strings.ToLower(statusName), "low") {
lowStockCount++
}
}
if lowStockCount > 0 {
fmt.Fprintf(&contextBuilder, "Note: %d item(s) are low on stock.\n", lowStockCount)
}
return contextBuilder.String(), nil
}
func (s *ChatService) buildGeneralContextString(ctx context.Context, userID string) (string, error) {
var contextBuilder strings.Builder
contextBuilder.WriteString("## General Farming Context ##\n")
farms, err := s.farmRepo.GetByOwnerID(ctx, userID)
if err == nil && len(farms) > 0 {
contextBuilder.WriteString("Your Farms:\n")
for i, farm := range farms {
if i >= 5 {
fmt.Fprintf(&contextBuilder, "- ... and %d more\n", len(farms)-5)
break
}
fmt.Fprintf(&contextBuilder, "- %s (Type: %s, Size: %s)\n", farm.Name, farm.FarmType, farm.TotalSize)
}
} else if err != nil {
s.logger.Warn("Failed to fetch farms for general context", "userId", userID, "error", err)
} else {
contextBuilder.WriteString("No farms found for your account.\n")
}
contextBuilder.WriteString("\n")
inventoryContext, _ := s.buildInventoryContextString(ctx, userID)
contextBuilder.WriteString(inventoryContext)
return contextBuilder.String(), nil
}
func (s *ChatService) GenerateResponse(ctx context.Context, input GenerateResponseInput) (string, error) {
var contextString string
var contextErr error
startTime := time.Now()
if input.CropID != "" {
s.logger.Debug("Building context for CROP", "cropId", input.CropID)
contextString, contextErr = s.buildCropContextString(ctx, input.CropID, input.UserID)
} else if input.FarmID != "" {
s.logger.Debug("Building context for FARM", "farmId", input.FarmID)
contextString, contextErr = s.buildFarmContextString(ctx, input.FarmID, input.UserID)
} else {
s.logger.Debug("Building GENERAL context", "userId", input.UserID)
contextString, contextErr = s.buildGeneralContextString(ctx, input.UserID)
}
newsContext, _ := s.retrieveDummyNews(ctx)
weatherOutlook, _ := s.retrieveDummyWeatherOutlook(ctx, input.FarmID)
fullContext := strings.Builder{}
fullContext.WriteString(contextString)
if contextErr != nil {
s.logger.Warn("Error building primary context string", "error", contextErr, "userId", input.UserID, "farmId", input.FarmID, "cropId", input.CropID)
fullContext.WriteString(fmt.Sprintf("Warning: Could not retrieve specific data (%s).\n\n", contextErr))
}
fullContext.WriteString(newsContext)
fullContext.WriteString(weatherOutlook)
contextDuration := time.Since(startTime)
s.logger.Debug("Context retrieval duration", "duration", contextDuration)
model := s.client.GenerativeModel("gemini-1.5-flash")
systemInstruction := `You are ForFarm Assistant, an expert AI specialized in agriculture and farming practices.
Your goal is to provide helpful, accurate, and concise advice to farmers using the ForFarm platform.
Use the provided context data about the user's specific farm, crops, or inventory when available to tailor your response.
If context is provided, prioritize answering based on that context.
If no specific context is available or relevant, provide general best-practice farming advice.
Focus on actionable recommendations where appropriate.
Keep responses focused on farming, agriculture, crop management, pest control, soil health, weather impacts, and inventory management.`
model.SystemInstruction = &genai.Content{Parts: []genai.Part{genai.Text(systemInstruction)}}
fullPrompt := fmt.Sprintf("%s\nUser Question: %s", strings.TrimSpace(fullContext.String()), input.Message)
session := model.StartChat()
session.History = input.History
s.logger.Info("Sending message to LLM", "userId", input.UserID, "historyLength", len(session.History), "contextLength", len(fullContext.String()))
resp, err := session.SendMessage(ctx, genai.Text(fullPrompt))
llmDuration := time.Since(startTime) - contextDuration
s.logger.Debug("LLM response duration", "duration", llmDuration)
if err != nil {
s.logger.Error("Error sending message to Gemini", "error", err)
return "Sorry, I encountered an error while generating a response.", fmt.Errorf("LLM communication failed: %w", err)
}
var responseText strings.Builder
if len(resp.Candidates) > 0 && resp.Candidates[0].Content != nil {
for _, part := range resp.Candidates[0].Content.Parts {
if txt, ok := part.(genai.Text); ok {
responseText.WriteString(string(txt))
}
}
}
if responseText.Len() == 0 {
s.logger.Warn("Received no valid text content from Gemini", "finishReason", resp.Candidates[0].FinishReason)
if resp.Candidates[0].FinishReason != genai.FinishReasonStop {
return fmt.Sprintf("My response generation was interrupted (%s). Could you please try rephrasing?", resp.Candidates[0].FinishReason), nil
}
return "I apologize, I couldn't generate a response for that request.", nil
}
s.logger.Info("Successfully generated chat response", "userId", input.UserID, "responseLength", responseText.Len())
return responseText.String(), nil
}
// Simulates fetching news with artificial delay and random selection
func (s *ChatService) retrieveDummyNews(ctx context.Context) (string, error) {
s.logger.Debug("Retrieving dummy news context")
time.Sleep(50 * time.Millisecond)
if rand.Intn(10) > 2 {
newsItems := []string{
"Global wheat prices show slight increase.",
"New organic pest control method using beneficial nematodes gaining traction.",
"Research highlights drought-resistant corn variety performance.",
"Government announces new subsidies for sustainable farming practices.",
}
return fmt.Sprintf("## Recent Agricultural News ##\n- %s\n\n", newsItems[rand.Intn(len(newsItems))]), nil
}
return "", nil
}
// Simulates fetching weather forecast with artificial delay and random selection
func (s *ChatService) retrieveDummyWeatherOutlook(ctx context.Context, farmID string) (string, error) {
s.logger.Debug("Retrieving dummy weather outlook context", "farmId", farmID)
time.Sleep(80 * time.Millisecond)
forecasts := []string{
"Clear skies expected for the next 3 days.",
"Chance of scattered showers tomorrow afternoon.",
"Temperature expected to rise towards the weekend.",
"Slightly higher winds predicted for Thursday.",
}
return fmt.Sprintf("## Weather Outlook ##\n- %s\n\n", forecasts[rand.Intn(len(forecasts))]), nil
}
func safeString(s *string) string {
if s == nil {
return "N/A"
}
return *s
}
func (s *ChatService) Close() {
if s.client != nil {
if err := s.client.Close(); err != nil {
s.logger.Error("Failed to close Gemini client", "error", err)
} else {
s.logger.Info("Gemini client closed.")
}
}
}

View File

@ -1,4 +1,3 @@
// backend/internal/services/weather/openweathermap_fetcher.go
package weather package weather
import ( import (
@ -14,45 +13,57 @@ import (
"github.com/forfarm/backend/internal/domain" "github.com/forfarm/backend/internal/domain"
) )
const openWeatherMapOneCallAPIURL = "https://api.openweathermap.org/data/3.0/onecall" const openWeatherMapCurrentAPIURL = "https://api.openweathermap.org/data/2.5/weather"
type openWeatherMapOneCallResponse struct { type openWeatherMapCurrentResponse struct {
Lat float64 `json:"lat"` Coord struct {
Lon float64 `json:"lon"` Lon float64 `json:"lon"`
Timezone string `json:"timezone"` Lat float64 `json:"lat"`
TimezoneOffset int `json:"timezone_offset"` } `json:"coord"`
Current *struct { Weather []struct {
Dt int64 `json:"dt"` // Current time, Unix, UTC ID int `json:"id"`
Sunrise int64 `json:"sunrise"` Main string `json:"main"`
Sunset int64 `json:"sunset"` Description string `json:"description"`
Temp float64 `json:"temp"` // Kelvin by default, 'units=metric' for Celsius Icon string `json:"icon"`
FeelsLike float64 `json:"feels_like"` // Kelvin by default } `json:"weather"`
Pressure int `json:"pressure"` // hPa Base string `json:"base"`
Humidity int `json:"humidity"` // % Main *struct {
DewPoint float64 `json:"dew_point"` Temp float64 `json:"temp"`
Uvi float64 `json:"uvi"` FeelsLike float64 `json:"feels_like"`
Clouds int `json:"clouds"` // % TempMin float64 `json:"temp_min"`
Visibility int `json:"visibility"` // meters TempMax float64 `json:"temp_max"`
WindSpeed float64 `json:"wind_speed"` // meter/sec by default Pressure int `json:"pressure"`
WindDeg int `json:"wind_deg"` Humidity int `json:"humidity"`
WindGust float64 `json:"wind_gust,omitempty"` SeaLevel int `json:"sea_level,omitempty"`
Rain *struct { GrndLevel int `json:"grnd_level,omitempty"`
OneH float64 `json:"1h"` // Rain volume for the last 1 hour, mm } `json:"main"`
} `json:"rain,omitempty"` Visibility int `json:"visibility"`
Snow *struct { Wind *struct {
OneH float64 `json:"1h"` // Snow volume for the last 1 hour, mm Speed float64 `json:"speed"`
} `json:"snow,omitempty"` Deg int `json:"deg"`
Weather []struct { Gust float64 `json:"gust,omitempty"`
ID int `json:"id"` } `json:"wind"`
Main string `json:"main"` Rain *struct {
Description string `json:"description"` OneH float64 `json:"1h"`
Icon string `json:"icon"` } `json:"rain,omitempty"`
} `json:"weather"` Snow *struct {
} `json:"current,omitempty"` OneH float64 `json:"1h"`
// Minutely []... } `json:"snow,omitempty"`
// Hourly []... Clouds *struct {
// Daily []... All int `json:"all"`
// Alerts []... } `json:"clouds"`
Dt int64 `json:"dt"`
Sys *struct {
Type int `json:"type,omitempty"`
ID int `json:"id,omitempty"`
Country string `json:"country"`
Sunrise int64 `json:"sunrise"`
Sunset int64 `json:"sunset"`
} `json:"sys"`
Timezone int `json:"timezone"`
ID int `json:"id"`
Name string `json:"name"`
Cod int `json:"cod"`
} }
type OpenWeatherMapFetcher struct { type OpenWeatherMapFetcher struct {
@ -80,11 +91,10 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
queryParams.Set("lat", fmt.Sprintf("%.4f", lat)) queryParams.Set("lat", fmt.Sprintf("%.4f", lat))
queryParams.Set("lon", fmt.Sprintf("%.4f", lon)) queryParams.Set("lon", fmt.Sprintf("%.4f", lon))
queryParams.Set("appid", f.apiKey) queryParams.Set("appid", f.apiKey)
queryParams.Set("units", "metric") // Request Celsius and m/s queryParams.Set("units", "metric")
queryParams.Set("exclude", "minutely,hourly,daily,alerts") // Exclude parts we don't need now
fullURL := fmt.Sprintf("%s?%s", openWeatherMapOneCallAPIURL, queryParams.Encode()) fullURL := fmt.Sprintf("%s?%s", openWeatherMapCurrentAPIURL, queryParams.Encode())
f.logger.Debug("Fetching weather from OpenWeatherMap OneCall API", "url", fullURL) f.logger.Debug("Fetching weather from OpenWeatherMap Current API", "url", fullURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil { if err != nil {
@ -100,7 +110,6 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
// TODO: Read resp.Body to get error message from OpenWeatherMap
bodyBytes, _ := io.ReadAll(resp.Body) bodyBytes, _ := io.ReadAll(resp.Body)
f.logger.Error("OpenWeatherMap API returned non-OK status", f.logger.Error("OpenWeatherMap API returned non-OK status",
"url", fullURL, "url", fullURL,
@ -109,46 +118,60 @@ func (f *OpenWeatherMapFetcher) GetCurrentWeatherByCoords(ctx context.Context, l
return nil, fmt.Errorf("weather API request failed with status: %s", resp.Status) return nil, fmt.Errorf("weather API request failed with status: %s", resp.Status)
} }
var owmResp openWeatherMapOneCallResponse var owmResp openWeatherMapCurrentResponse
if err := json.NewDecoder(resp.Body).Decode(&owmResp); err != nil { if err := json.NewDecoder(resp.Body).Decode(&owmResp); err != nil {
f.logger.Error("Failed to decode OpenWeatherMap OneCall response", "error", err) f.logger.Error("Failed to decode OpenWeatherMap Current response", "error", err)
return nil, fmt.Errorf("failed to decode weather response: %w", err) return nil, fmt.Errorf("failed to decode weather response: %w", err)
} }
if owmResp.Current == nil { // --- Data Mapping from openWeatherMapCurrentResponse to domain.WeatherData ---
f.logger.Warn("OpenWeatherMap OneCall response missing 'current' weather data", "lat", lat, "lon", lon)
return nil, fmt.Errorf("current weather data not found in API response")
}
current := owmResp.Current
if len(current.Weather) == 0 { if owmResp.Main == nil {
f.logger.Warn("OpenWeatherMap response missing weather description details", "lat", lat, "lon", lon) f.logger.Error("OpenWeatherMap Current response missing 'main' data block", "lat", lat, "lon", lon)
return nil, fmt.Errorf("weather data description not found in response") return nil, fmt.Errorf("main weather data block not found in API response")
} }
// Create domain object using pointers for optional fields weatherData := &domain.WeatherData{}
weatherData := &domain.WeatherData{} // Initialize empty struct first
// Assign values using pointers, checking for nil where appropriate weatherData.TempCelsius = &owmResp.Main.Temp
weatherData.TempCelsius = &current.Temp humidityFloat := float64(owmResp.Main.Humidity)
humidityFloat := float64(current.Humidity)
weatherData.Humidity = &humidityFloat weatherData.Humidity = &humidityFloat
weatherData.Description = &current.Weather[0].Description
weatherData.Icon = &current.Weather[0].Icon if len(owmResp.Weather) > 0 {
weatherData.WindSpeed = &current.WindSpeed weatherData.Description = &owmResp.Weather[0].Description
if current.Rain != nil { weatherData.Icon = &owmResp.Weather[0].Icon
weatherData.RainVolume1h = &current.Rain.OneH } else {
f.logger.Warn("OpenWeatherMap Current response missing 'weather' description details", "lat", lat, "lon", lon)
} }
observedTime := time.Unix(current.Dt, 0).UTC()
if owmResp.Wind != nil {
weatherData.WindSpeed = &owmResp.Wind.Speed
} else {
f.logger.Warn("OpenWeatherMap Current response missing 'wind' data block", "lat", lat, "lon", lon)
}
if owmResp.Rain != nil {
weatherData.RainVolume1h = &owmResp.Rain.OneH
}
observedTime := time.Unix(owmResp.Dt, 0).UTC()
weatherData.ObservedAt = &observedTime weatherData.ObservedAt = &observedTime
now := time.Now().UTC() now := time.Now().UTC()
weatherData.WeatherLastUpdated = &now weatherData.WeatherLastUpdated = &now
f.logger.Debug("Successfully fetched weather data", logTemp := "nil"
if weatherData.TempCelsius != nil {
logTemp = fmt.Sprintf("%.2f", *weatherData.TempCelsius)
}
logDesc := "nil"
if weatherData.Description != nil {
logDesc = *weatherData.Description
}
f.logger.Debug("Successfully fetched and mapped weather data",
"lat", lat, "lat", lat,
"lon", lon, "lon", lon,
"temp", *weatherData.TempCelsius, "temp", logTemp,
"description", *weatherData.Description) "description", logDesc)
return weatherData, nil return weatherData, nil
} }

View File

@ -3,6 +3,7 @@ package workers
import ( import (
"context" "context"
"fmt"
"log/slog" "log/slog"
"sync" "sync"
"time" "time"
@ -27,13 +28,23 @@ func NewWeatherUpdater(
eventPublisher domain.EventPublisher, eventPublisher domain.EventPublisher,
logger *slog.Logger, logger *slog.Logger,
fetchInterval time.Duration, fetchInterval time.Duration,
) *WeatherUpdater { ) (*WeatherUpdater, error) {
if logger == nil { if logger == nil {
logger = slog.Default() logger = slog.Default()
} }
if fetchInterval <= 0 { if fetchInterval <= 0 {
fetchInterval = 15 * time.Minute fetchInterval = 60 * time.Minute
} }
if farmRepo == nil {
return nil, fmt.Errorf("farmRepo cannot be nil")
}
if weatherFetcher == nil {
return nil, fmt.Errorf("weatherFetcher cannot be nil")
}
if eventPublisher == nil {
return nil, fmt.Errorf("eventPublisher cannot be nil")
}
return &WeatherUpdater{ return &WeatherUpdater{
farmRepo: farmRepo, farmRepo: farmRepo,
weatherFetcher: weatherFetcher, weatherFetcher: weatherFetcher,
@ -41,7 +52,7 @@ func NewWeatherUpdater(
logger: logger, logger: logger,
fetchInterval: fetchInterval, fetchInterval: fetchInterval,
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
} }, nil
} }
func (w *WeatherUpdater) Start(ctx context.Context) { func (w *WeatherUpdater) Start(ctx context.Context) {
@ -75,20 +86,22 @@ func (w *WeatherUpdater) Start(ctx context.Context) {
func (w *WeatherUpdater) Stop() { func (w *WeatherUpdater) Stop() {
w.logger.Info("Attempting to stop Weather Updater worker...") w.logger.Info("Attempting to stop Weather Updater worker...")
close(w.stopChan) select {
w.wg.Wait() case <-w.stopChan:
default:
close(w.stopChan)
}
w.wg.Wait() // Wait for the goroutine to finish
w.logger.Info("Weather Updater worker stopped") w.logger.Info("Weather Updater worker stopped")
} }
func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) { func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
// Use a background context for the repository call if the main context might cancel prematurely repoCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Use separate context for DB query
// repoCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) // Example timeout defer cancel()
// defer cancel()
// TODO: Need a GetAllFarms method in the FarmRepository or a way to efficiently get all farm locations. farms, err := w.farmRepo.GetAll(repoCtx) // <-- Changed method call
farms, err := w.farmRepo.GetByOwnerID(ctx, "") // !! REPLACE with a proper GetAll method !!
if err != nil { if err != nil {
w.logger.Error("Failed to get farms for weather update", "error", err) w.logger.Error("Failed to get all farms for weather update", "error", err)
return return
} }
if len(farms) == 0 { if len(farms) == 0 {
@ -96,12 +109,15 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
return return
} }
w.logger.Info("Found farms for weather update", "count", len(farms)) w.logger.Info("Processing farms for weather update", "count", len(farms))
var fetchWg sync.WaitGroup var fetchWg sync.WaitGroup
fetchCtx, cancelFetches := context.WithCancel(ctx) fetchCtx, cancelFetches := context.WithCancel(ctx)
defer cancelFetches() defer cancelFetches()
concurrencyLimit := 5
sem := make(chan struct{}, concurrencyLimit)
for _, farm := range farms { for _, farm := range farms {
if farm.Lat == 0 && farm.Lon == 0 { if farm.Lat == 0 && farm.Lon == 0 {
w.logger.Warn("Skipping farm with zero coordinates", "farm_id", farm.UUID, "farm_name", farm.Name) w.logger.Warn("Skipping farm with zero coordinates", "farm_id", farm.UUID, "farm_name", farm.Name)
@ -109,10 +125,14 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
} }
fetchWg.Add(1) fetchWg.Add(1)
sem <- struct{}{}
go func(f domain.Farm) { go func(f domain.Farm) {
defer fetchWg.Done() defer fetchWg.Done()
defer func() { <-sem }()
select { select {
case <-fetchCtx.Done(): case <-fetchCtx.Done():
w.logger.Info("Weather fetch cancelled for farm", "farm_id", f.UUID, "reason", fetchCtx.Err())
return return
default: default:
w.fetchAndPublishWeather(fetchCtx, f) w.fetchAndPublishWeather(fetchCtx, f)
@ -121,7 +141,7 @@ func (w *WeatherUpdater) fetchAndUpdateAllFarms(ctx context.Context) {
} }
fetchWg.Wait() fetchWg.Wait()
w.logger.Info("Finished weather fetch cycle for farms", "count", len(farms)) w.logger.Debug("Finished weather fetch cycle for farms", "count", len(farms)) // Use Debug for cycle completion
} }
func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain.Farm) { func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain.Farm) {
@ -136,17 +156,17 @@ func (w *WeatherUpdater) fetchAndPublishWeather(ctx context.Context, farm domain
} }
payloadMap := map[string]interface{}{ payloadMap := map[string]interface{}{
"farm_id": farm.UUID, "farm_id": farm.UUID,
"lat": farm.Lat, "lat": farm.Lat,
"lon": farm.Lon, "lon": farm.Lon,
"temp_celsius": weatherData.TempCelsius, "tempCelsius": weatherData.TempCelsius,
"humidity": weatherData.Humidity, "humidity": weatherData.Humidity,
"description": weatherData.Description, "description": weatherData.Description,
"icon": weatherData.Icon, "icon": weatherData.Icon,
"wind_speed": weatherData.WindSpeed, "windSpeed": weatherData.WindSpeed,
"rain_volume_1h": weatherData.RainVolume1h, "rainVolume1h": weatherData.RainVolume1h,
"observed_at": weatherData.ObservedAt, "observedAt": weatherData.ObservedAt,
"weather_last_updated": weatherData.WeatherLastUpdated, "weatherLastUpdated": weatherData.WeatherLastUpdated,
} }
event := domain.Event{ event := domain.Event{

View File

@ -17,8 +17,8 @@ export interface RegisterResponse {
export async function registerUser(email: string, password: string): Promise<RegisterResponse> { export async function registerUser(email: string, password: string): Promise<RegisterResponse> {
try { try {
const response = await axiosInstance.post("/auth/register", { const response = await axiosInstance.post("/auth/register", {
Email: email, email: email,
Password: password, password: password,
}); });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
@ -35,8 +35,8 @@ export async function registerUser(email: string, password: string): Promise<Reg
export async function loginUser(email: string, password: string): Promise<LoginResponse> { export async function loginUser(email: string, password: string): Promise<LoginResponse> {
try { try {
const response = await axiosInstance.post("/auth/login", { const response = await axiosInstance.post("/auth/login", {
Email: email, email: email,
Password: password, password: password,
}); });
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {

73
frontend/api/chat.ts Normal file
View File

@ -0,0 +1,73 @@
import axiosInstance from "./config";
interface HistoryItem {
role: "user" | "model" | "assistant";
text: string;
}
interface ChatResponse {
response: string;
// Include history if backend returns it, otherwise frontend manages it
}
// Map frontend 'assistant' role to backend 'model' role if needed
const mapRoleForApi = (role: "user" | "assistant"): "user" | "model" => {
return role === "assistant" ? "model" : role;
};
/**
* Sends a chat message to the backend.
* Determines the endpoint based on the presence of farmId and cropId.
*
* @param message The user's message.
* @param history The conversation history.
* @param farmId Optional farm ID for context.
* @param cropId Optional crop ID for context.
* @returns The assistant's response.
*/
export async function sendChatMessage(
message: string,
history: HistoryItem[],
farmId?: string | null,
cropId?: string | null
): Promise<ChatResponse> {
const endpoint = farmId || cropId ? "/chat/specific" : "/chat"; // Use /chatbot if no IDs
const apiHistory = history.map((item) => ({
role: mapRoleForApi(item.role as "user" | "assistant"),
text: item.text,
}));
const payload: {
message: string;
farmId?: string;
cropId?: string;
history: { role: "user" | "model"; text: string }[];
} = {
message,
history: apiHistory,
};
// Only include IDs if calling the contextual endpoint
if (farmId || cropId) {
if (farmId) payload.farmId = farmId;
if (cropId) payload.cropId = cropId;
}
console.log(`Sending chat message to ${endpoint} with payload:`, payload);
try {
const response = await axiosInstance.post<ChatResponse>(endpoint, payload);
console.log("Received chat response:", response.data);
return response.data;
} catch (error) {
console.error(`Error sending chat message to ${endpoint}:`, error);
// Provide a user-friendly error message
const errorMessage =
(error as any).response?.data?.message ||
"Sorry, I couldn't connect to the assistant right now. Please try again later.";
// Throw an error with a useful message or return a specific error structure
// throw new Error(errorMessage);
// Or return an error response structure:
return { response: errorMessage };
}
}

View File

@ -1,5 +1,5 @@
// frontend/api/crop.ts
import axiosInstance from "./config"; import axiosInstance from "./config";
// Use refactored types
import type { Cropland, CropAnalytics } from "@/types"; import type { Cropland, CropAnalytics } from "@/types";
export interface CropResponse { export interface CropResponse {
@ -7,30 +7,72 @@ export interface CropResponse {
} }
/** /**
* Fetch all Croplands for a specific FarmID. Returns CropResponse. * Fetch all Croplands for a specific FarmID.
*/ */
export async function getCropsByFarmId(farmId: string): Promise<CropResponse> { export async function getCropsByFarmId(farmId: string): Promise<CropResponse> {
// Assuming backend returns { "croplands": [...] } return axiosInstance.get<{ croplands: Cropland[] }>(`/crop/farm/${farmId}`).then((res) => res.data);
return axiosInstance.get<CropResponse>(`/crop/farm/${farmId}`).then((res) => res.data);
} }
/** /**
* Fetch a specific Cropland by its ID. Returns Cropland. * Fetch a specific Cropland by its ID.
*/ */
export async function getCropById(cropId: string): Promise<Cropland> { export async function getCropById(cropId: string): Promise<Cropland> {
// Assuming backend returns { "cropland": ... } const response = await axiosInstance.get<{ cropland: Cropland }>(`/crop/${cropId}`);
return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data); return response.data.cropland;
// If backend returns object directly: return axiosInstance.get<Cropland>(`/crop/${cropId}`).then((res) => res.data);
} }
/** /**
* Create a new crop (Cropland). Sends camelCase data matching backend tags. Returns Cropland. * Create a new crop (Cropland).
*/ */
export async function createCrop(data: Partial<Omit<Cropland, "uuid" | "createdAt" | "updatedAt">>): Promise<Cropland> { export async function createCrop(data: {
name: string;
status: string;
priority?: number;
landSize?: number;
growthStage: string;
plantId: string;
farmId: string;
geoFeature?: unknown | null;
}): Promise<Cropland> {
if (!data.farmId) { if (!data.farmId) {
throw new Error("farmId is required to create a crop."); 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 ?? 0,
landSize: data.landSize ?? 0,
growthStage: data.growthStage,
plantId: data.plantId,
farmId: data.farmId,
geoFeature: data.geoFeature,
};
const response = await axiosInstance.post<{ cropland: Cropland }>(`/crop`, payload);
return response.data.cropland;
}
/**
* Update an existing cropland by its ID.
* Note: farmId cannot be changed via this endpoint
*/
export async function updateCrop(
cropId: string,
data: {
name: string;
status: string;
priority: number;
landSize: number;
growthStage: string;
plantId: string;
geoFeature: unknown | null;
}
): Promise<Cropland> {
if (!cropId) {
throw new Error("cropId is required to update a crop.");
}
const payload = { const payload = {
name: data.name, name: data.name,
status: data.status, status: data.status,
@ -38,17 +80,36 @@ export async function createCrop(data: Partial<Omit<Cropland, "uuid" | "createdA
landSize: data.landSize, landSize: data.landSize,
growthStage: data.growthStage, growthStage: data.growthStage,
plantId: data.plantId, plantId: data.plantId,
farmId: data.farmId, geoFeature: data.geoFeature,
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); const response = await axiosInstance.put<{ cropland: Cropland }>(`/crop/${cropId}`, payload);
return response.data.cropland;
} }
/** /**
* Fetch analytics data for a specific crop by its ID. Returns CropAnalytics. * Delete a specific cropland by its ID.
*/ */
export async function fetchCropAnalytics(cropId: string): Promise<CropAnalytics> { export async function deleteCrop(cropId: string): Promise<{ message: string } | void> {
// Assuming backend returns { body: { ... } } structure from Huma const response = await axiosInstance.delete(`/crop/${cropId}`);
return axiosInstance.get<CropAnalytics>(`/analytics/crop/${cropId}`).then((res) => res.data); if (response.status === 204) {
return;
}
return response.data as { message: string };
}
/**
* Fetch analytics data for a specific crop by its ID.
*/
export async function fetchCropAnalytics(cropId: string): Promise<CropAnalytics | null> {
try {
const response = await axiosInstance.get<CropAnalytics>(`/analytics/crop/${cropId}`);
return response.data;
} catch (error: any) {
console.error("Error fetching crop analytics:", error);
if (error.response?.status === 404) {
return null;
}
throw error;
}
} }

View File

@ -2,204 +2,69 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import { Send, MessageSquare, Sparkles, Loader2, User, Bot } from "lucide-react";
ChevronLeft,
Send,
Clock,
X,
Leaf,
MessageSquare,
History,
PanelRightClose,
PanelRightOpen,
Search,
Sparkles,
} from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Avatar } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; // Assuming Avatar is in ui folder
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { sendChatMessage } from "@/api/chat"; // Import the API function
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import type { Farm, Crop } from "@/types";
// Mock data for farms and crops // Interface for chat messages
const mockFarms: Farm[] = [
{
id: "farm1",
name: "Green Valley Farm",
location: "California",
type: "Organic",
createdAt: new Date("2023-01-15"),
area: "120 acres",
crops: 8,
weather: {
temperature: 24,
humidity: 65,
rainfall: "2mm",
sunlight: 80,
},
},
{
id: "farm2",
name: "Sunrise Fields",
location: "Iowa",
type: "Conventional",
createdAt: new Date("2022-11-05"),
area: "350 acres",
crops: 5,
weather: {
temperature: 22,
humidity: 58,
rainfall: "0mm",
sunlight: 90,
},
},
];
const mockCrops: Crop[] = [
{
id: "crop1",
farmId: "farm1",
name: "Organic Tomatoes",
plantedDate: new Date("2023-03-10"),
status: "Growing",
variety: "Roma",
area: "15 acres",
healthScore: 92,
progress: 65,
},
{
id: "crop2",
farmId: "farm1",
name: "Sweet Corn",
plantedDate: new Date("2023-04-05"),
status: "Growing",
variety: "Golden Bantam",
area: "25 acres",
healthScore: 88,
progress: 45,
},
{
id: "crop3",
farmId: "farm2",
name: "Soybeans",
plantedDate: new Date("2023-05-15"),
status: "Growing",
variety: "Pioneer",
area: "120 acres",
healthScore: 95,
progress: 30,
},
];
// Mock chat history
interface ChatMessage { interface ChatMessage {
id: string; id: string;
role: "user" | "assistant"; // Changed sender to role
content: string; content: string;
sender: "user" | "bot";
timestamp: Date; timestamp: Date;
relatedTo?: {
type: "farm" | "crop";
id: string;
name: string;
};
} }
const mockChatHistory: ChatMessage[] = [ // Recommended prompts (keep or adjust as needed)
{
id: "msg1",
content: "When should I harvest my tomatoes?",
sender: "user",
timestamp: new Date("2023-07-15T10:30:00"),
relatedTo: {
type: "crop",
id: "crop1",
name: "Organic Tomatoes",
},
},
{
id: "msg2",
content:
"Based on the current growth stage of your Roma tomatoes, they should be ready for harvest in approximately 2-3 weeks. The ideal time to harvest is when they've developed their full red color but are still firm to the touch. Keep monitoring the soil moisture levels as consistent watering during the final ripening stage is crucial for flavor development.",
sender: "bot",
timestamp: new Date("2023-07-15T10:30:30"),
},
{
id: "msg3",
content: "What's the best fertilizer for corn?",
sender: "user",
timestamp: new Date("2023-07-16T14:22:00"),
relatedTo: {
type: "crop",
id: "crop2",
name: "Sweet Corn",
},
},
{
id: "msg4",
content:
"For your Sweet Corn at Green Valley Farm, I recommend a nitrogen-rich fertilizer with an NPK ratio of approximately 16-4-8. Corn is a heavy nitrogen feeder, especially during its growth phase. Apply the fertilizer when the plants are knee-high and again when they begin to tassel. Based on your soil analysis, consider supplementing with sulfur to address the slight deficiency detected in your last soil test.",
sender: "bot",
timestamp: new Date("2023-07-16T14:22:45"),
},
];
// Recommended prompts
const recommendedPrompts = [ const recommendedPrompts = [
{ {
id: "prompt1", id: "prompt1",
text: "When should I water my crops?", text: "What are common signs of nutrient deficiency in plants?",
category: "Irrigation", category: "Plant Health",
}, },
{ {
id: "prompt2", id: "prompt2",
text: "How can I improve soil health?", text: "How can I improve soil drainage?",
category: "Soil Management", category: "Soil Management",
}, },
{ {
id: "prompt3", id: "prompt3",
text: "What pests might affect my crops this season?", text: "Explain integrated pest management (IPM).",
category: "Pest Control", category: "Pest Control",
}, },
{ {
id: "prompt4", id: "prompt4",
text: "Recommend a crop rotation plan", text: "What are the benefits of crop rotation?",
category: "Planning", category: "Planning",
}, },
{ {
id: "prompt5", id: "prompt5",
text: "How to maximize yield for my current crops?", text: "Tell me about sustainable farming practices.",
category: "Optimization", category: "Sustainability",
}, },
{ {
id: "prompt6", id: "prompt6",
text: "What's the best time to harvest?", text: "How does weather affect crop yield?",
category: "Harvesting", category: "Weather",
}, },
]; ];
export default function ChatbotPage() { export default function GeneralChatbotPage() {
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [isHistoryOpen, setIsHistoryOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); // Loading state for API call
const [selectedFarm, setSelectedFarm] = useState<string | null>(null);
const [selectedCrop, setSelectedCrop] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const router = useRouter();
// Initialize with a welcome message // Initialize with a welcome message
useEffect(() => { useEffect(() => {
setMessages([ setMessages([
{ {
id: "welcome", id: `assistant-${Date.now()}`,
role: "assistant",
content: content:
"👋 Hello! I'm ForFarm Assistant, your farming AI companion. How can I help you today? You can ask me about crop management, pest control, weather impacts, or select a specific farm or crop to get tailored advice.", "👋 Hello! I'm ForFarm Assistant, your general farming AI companion. Ask me anything about agriculture, crops, soil, weather, or best practices!",
sender: "bot",
timestamp: new Date(), timestamp: new Date(),
}, },
]); ]);
@ -210,483 +75,181 @@ export default function ChatbotPage() {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]); }, [messages]);
// Filter crops based on selected farm
const filteredCrops = selectedFarm ? mockCrops.filter((crop) => crop.farmId === selectedFarm) : mockCrops;
// Handle sending a message // Handle sending a message
const handleSendMessage = (content: string = inputValue) => { const handleSendMessage = async (content: string = inputValue) => {
if (!content.trim()) return; if (!content.trim() || isLoading) return;
// Create user message
const userMessage: ChatMessage = { const userMessage: ChatMessage = {
id: `user-${Date.now()}`, id: `user-${Date.now()}`,
role: "user",
content, content,
sender: "user",
timestamp: new Date(), timestamp: new Date(),
...(selectedFarm || selectedCrop
? {
relatedTo: {
type: selectedCrop ? "crop" : "farm",
id: selectedCrop || selectedFarm || "",
name: selectedCrop
? mockCrops.find((c) => c.id === selectedCrop)?.name || ""
: mockFarms.find((f) => f.id === selectedFarm)?.name || "",
},
}
: {}),
}; };
setMessages((prev) => [...prev, userMessage]); setMessages((prev) => [...prev, userMessage]);
setInputValue(""); setInputValue("");
setIsLoading(true); setIsLoading(true);
// Simulate bot response after a delay // Prepare history for the API call
setTimeout(() => { const apiHistory = messages
const botResponse: ChatMessage = { .filter((msg) => msg.role === "user" || msg.role === "assistant")
id: `bot-${Date.now()}`, .map((msg) => ({ role: msg.role, text: msg.content }));
content: generateBotResponse(content, selectedFarm, selectedCrop),
sender: "bot", try {
// Call the API function *without* farmId and cropId
const response = await sendChatMessage(userMessage.content, apiHistory);
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: response.response,
timestamp: new Date(), timestamp: new Date(),
}; };
setMessages((prev) => [...prev, assistantMessage]);
setMessages((prev) => [...prev, botResponse]); } catch (error) {
console.error("Error sending general chat message:", error);
const errorMessage: ChatMessage = {
id: `error-${Date.now()}`,
role: "assistant",
content: `Sorry, I encountered an issue. ${(error as Error).message || "Please try again later."}`,
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false); setIsLoading(false);
}, 1500);
};
// Generate a bot response based on the user's message and selected farm/crop
const generateBotResponse = (message: string, farmId: string | null, cropId: string | null): string => {
const lowerMessage = message.toLowerCase();
// Get farm and crop details if selected
const farm = farmId ? mockFarms.find((f) => f.id === farmId) : null;
const crop = cropId ? mockCrops.find((c) => c.id === cropId) : null;
// Personalize response based on selected farm/crop
let contextPrefix = "";
if (crop) {
contextPrefix = `For your ${crop.name} (${crop.variety}) at ${farm?.name || "your farm"}, `;
} else if (farm) {
contextPrefix = `For ${farm.name}, `;
} }
// Generate response based on message content
if (lowerMessage.includes("water") || lowerMessage.includes("irrigation")) {
return `${contextPrefix}I recommend watering deeply but infrequently to encourage strong root growth. Based on the current weather conditions${
farm ? ` in ${farm.location}` : ""
} (${farm?.weather?.rainfall || "minimal"} rainfall recently), you should water ${
crop ? `your ${crop.name}` : "your crops"
} approximately 2-3 times per week, ensuring the soil remains moist but not waterlogged.`;
} else if (lowerMessage.includes("fertiliz") || lowerMessage.includes("nutrient")) {
return `${contextPrefix}a balanced NPK fertilizer with a ratio of 10-10-10 would be suitable for general application. ${
crop
? `For ${crop.name} specifically, consider increasing ${
crop.name.toLowerCase().includes("tomato")
? "potassium"
: crop.name.toLowerCase().includes("corn")
? "nitrogen"
: "phosphorus"
} for optimal growth during the current ${
crop.progress && crop.progress < 30 ? "early" : crop.progress && crop.progress < 70 ? "middle" : "late"
} growth stage.`
: ""
}`;
} else if (lowerMessage.includes("pest") || lowerMessage.includes("insect") || lowerMessage.includes("disease")) {
return `${contextPrefix}monitor for ${
crop
? crop.name.toLowerCase().includes("tomato")
? "tomato hornworms, aphids, and early blight"
: crop.name.toLowerCase().includes("corn")
? "corn borers, rootworms, and rust"
: "common agricultural pests"
: "common agricultural pests like aphids, beetles, and fungal diseases"
}. I recommend implementing integrated pest management (IPM) practices, including regular scouting, beneficial insects, and targeted treatments only when necessary.`;
} else if (lowerMessage.includes("harvest") || lowerMessage.includes("yield")) {
return `${contextPrefix}${
crop
? `your ${crop.name} should be ready to harvest in approximately ${Math.max(
1,
Math.round((100 - (crop.progress || 50)) / 10)
)} weeks based on the current growth stage. Look for ${
crop.name.toLowerCase().includes("tomato")
? "firm, fully colored fruits"
: crop.name.toLowerCase().includes("corn")
? "full ears with dried silk and plump kernels"
: "signs of maturity specific to this crop type"
}`
: "harvest timing depends on the specific crops you're growing, but generally you should look for visual cues of ripeness and maturity"
}.`;
} else if (lowerMessage.includes("soil") || lowerMessage.includes("compost")) {
return `${contextPrefix}improving soil health is crucial for sustainable farming. I recommend regular soil testing, adding organic matter through compost or cover crops, practicing crop rotation, and minimizing soil disturbance. ${
farm
? `Based on the soil type common in ${farm.location}, you might also consider adding ${
farm.location.includes("California") ? "gypsum to improve drainage" : "lime to adjust pH levels"
}.`
: ""
}`;
} else if (lowerMessage.includes("weather") || lowerMessage.includes("forecast") || lowerMessage.includes("rain")) {
return `${contextPrefix}${
farm
? `the current conditions show temperature at ${farm.weather?.temperature}°C with ${farm.weather?.humidity}% humidity. There's been ${farm.weather?.rainfall} of rainfall recently, and sunlight levels are at ${farm.weather?.sunlight}% of optimal.`
: "I recommend checking your local agricultural weather service for the most accurate forecast for your specific location."
} ${
crop
? `For your ${crop.name}, ${
farm?.weather?.rainfall === "0mm"
? "the dry conditions mean you should increase irrigation"
: "the recent rainfall means you can reduce irrigation temporarily"
}.`
: ""
}`;
} else {
return `${contextPrefix}I understand you're asking about "${message}". To provide the most helpful advice, could you provide more specific details about your farming goals or challenges? I'm here to help with crop management, pest control, irrigation strategies, and more.`;
}
};
// Handle selecting a farm
const handleFarmSelect = (farmId: string) => {
setSelectedFarm(farmId);
setSelectedCrop(null); // Reset crop selection when farm changes
};
// Handle selecting a crop
const handleCropSelect = (cropId: string) => {
setSelectedCrop(cropId);
}; };
// Handle clicking a recommended prompt // Handle clicking a recommended prompt
const handlePromptClick = (promptText: string) => { const handlePromptClick = (promptText: string) => {
setInputValue(promptText); setInputValue(promptText);
handleSendMessage(promptText); // Directly send message after setting input
}; // The handleSendMessage function will pick up the new inputValue
// No need to call handleSendMessage here if the button triggers submit or calls it
// Handle loading a chat history item // Let's assume the button just sets the input and the user clicks send
const handleLoadChatHistory = (messageId: string) => { // OR: uncomment the line below if the button should send immediately
// Find the message in history // handleSendMessage(promptText);
const historyItem = mockChatHistory.find((msg) => msg.id === messageId);
if (!historyItem) return;
// Set related farm/crop if available
if (historyItem.relatedTo) {
if (historyItem.relatedTo.type === "farm") {
setSelectedFarm(historyItem.relatedTo.id);
setSelectedCrop(null);
} else if (historyItem.relatedTo.type === "crop") {
const crop = mockCrops.find((c) => c.id === historyItem.relatedTo?.id);
if (crop) {
setSelectedFarm(crop.farmId);
setSelectedCrop(historyItem.relatedTo.id);
}
}
}
// Load the conversation
const conversation = mockChatHistory.filter(
(msg) =>
msg.id === messageId ||
(msg.timestamp >= historyItem.timestamp && msg.timestamp <= new Date(historyItem.timestamp.getTime() + 60000))
);
setMessages(conversation);
setIsHistoryOpen(false);
}; };
return ( return (
<div className="flex flex-col min-h-screen bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950"> <div className="flex flex-col h-[calc(100vh-4rem)] bg-gradient-to-br from-green-50 to-blue-50 dark:from-green-950 dark:to-blue-950">
{/* Header */} {/* Header (Optional - can be simplified as it's part of the main layout) */}
<header className="border-b bg-white dark:bg-gray-950 shadow-sm py-4 px-6"> <header className="border-b bg-white dark:bg-gray-950 shadow-sm py-4 px-6 flex items-center gap-2">
<div className="flex items-center justify-between"> <MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
<div className="flex items-center gap-2"> <h1 className="text-xl font-semibold">General Farming Assistant</h1>
<Button variant="ghost" size="icon" onClick={() => router.push("/farms")} aria-label="Back to farms">
<ChevronLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-green-600 dark:text-green-400" />
<h1 className="text-xl font-semibold">ForFarm Assistant</h1>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => setIsHistoryOpen(!isHistoryOpen)}
aria-label={isHistoryOpen ? "Close history" : "Open history"}>
{isHistoryOpen ? <PanelRightClose className="h-5 w-5" /> : <PanelRightOpen className="h-5 w-5" />}
</Button>
</div>
</header> </header>
{/* Main content */} {/* Chat Area */}
<div className="flex flex-1 overflow-hidden"> <ScrollArea className="flex-1 p-4">
{/* Chat area */} <div className="space-y-6 max-w-4xl mx-auto pb-4">
<div className="flex-1 flex flex-col h-full overflow-hidden"> {messages.map((message, i) => (
{/* Farm/Crop selector */} <div
<div className="bg-white dark:bg-gray-900 p-4 border-b"> key={message.id || i}
<div className="flex flex-col sm:flex-row gap-3"> className={`flex items-start gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
<div className="flex-1"> {message.role === "assistant" && (
<label className="text-sm font-medium mb-1 block text-gray-700 dark:text-gray-300"> <Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
Select Farm (Optional) <AvatarFallback>
</label> <Bot className="h-5 w-5 text-green-600" />
<Select value={selectedFarm || ""} onValueChange={handleFarmSelect}> </AvatarFallback>
<SelectTrigger className="w-full"> </Avatar>
<SelectValue placeholder="All Farms" /> )}
</SelectTrigger> <div
<SelectContent> className={`max-w-[80%] rounded-lg p-3 shadow-sm ${
<SelectItem value="all">All Farms</SelectItem> message.role === "user"
{mockFarms.map((farm) => ( ? "bg-green-600 text-white dark:bg-green-700"
<SelectItem key={farm.id} value={farm.id}> : "bg-white dark:bg-gray-800 border dark:border-gray-700"
{farm.name} }`}>
</SelectItem> <p className="text-sm whitespace-pre-wrap">{message.content}</p>
))} <p
</SelectContent> className={`mt-1 text-xs opacity-70 ${
</Select> message.role === "user" ? "text-green-100" : "text-gray-500 dark:text-gray-400"
</div> } text-right`}>
<div className="flex-1"> {message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
<label className="text-sm font-medium mb-1 block text-gray-700 dark:text-gray-300"> </p>
Select Crop (Optional) </div>
</label> {message.role === "user" && (
<Select value={selectedCrop || ""} onValueChange={handleCropSelect} disabled={!selectedFarm}> <Avatar className="h-8 w-8 border bg-gray-200 dark:bg-gray-600 flex-shrink-0">
<SelectTrigger className="w-full"> <AvatarFallback>
<SelectValue placeholder={selectedFarm ? "All Crops" : "Select a farm first"} /> <User className="h-5 w-5 text-gray-600 dark:text-gray-300" />
</SelectTrigger> </AvatarFallback>
<SelectContent> </Avatar>
{selectedFarm && <SelectItem value="all">All Crops</SelectItem>}
{filteredCrops.map((crop) => (
<SelectItem key={crop.id} value={crop.id}>
{crop.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Messages */}
<ScrollArea className="flex-1 p-4">
<div className="space-y-4 max-w-3xl mx-auto">
{messages.map((message) => (
<div key={message.id} className={`flex ${message.sender === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-lg p-4 ${
message.sender === "user"
? "bg-green-600 text-white dark:bg-green-700"
: "bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm"
}`}>
{message.relatedTo && (
<div className="mb-1">
<Badge variant="outline" className="text-xs font-normal">
{message.relatedTo.type === "farm" ? "🏡 " : "🌱 "}
{message.relatedTo.name}
</Badge>
</div>
)}
<div className="text-sm">{message.content}</div>
<div className="mt-1 text-xs opacity-70 text-right">
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-lg p-4 bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm">
<div className="flex items-center gap-2">
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-150"></div>
<div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-300"></div>
<span className="text-sm text-gray-500 dark:text-gray-400 ml-1">
ForFarm Assistant is typing...
</span>
</div>
</div>
</div>
)} )}
<div ref={messagesEndRef} />
</div> </div>
</ScrollArea> ))}
{isLoading && (
{/* Recommended prompts */} <div className="flex items-start gap-3">
<div className="bg-white dark:bg-gray-900 border-t p-4"> <Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1"> <AvatarFallback>
<Sparkles className="h-4 w-4 text-green-500" /> <Bot className="h-5 w-5 text-green-600" />
Recommended Questions </AvatarFallback>
</h3> </Avatar>
<div className="flex flex-wrap gap-2"> <div className="max-w-[80%] rounded-lg p-3 bg-white dark:bg-gray-800 border dark:border-gray-700 shadow-sm">
{recommendedPrompts.map((prompt) => ( <div className="flex items-center gap-2">
<Button <div className="h-2 w-2 bg-green-500 rounded-full animate-pulse"></div>
key={prompt.id} <div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-150"></div>
variant="outline" <div className="h-2 w-2 bg-green-500 rounded-full animate-pulse delay-300"></div>
size="sm" <span className="text-sm text-gray-500 dark:text-gray-400 ml-1">Assistant is thinking...</span>
className="text-xs rounded-full bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40 text-green-800 dark:text-green-300" </div>
onClick={() => handlePromptClick(prompt.text)}>
{prompt.text}
</Button>
))}
</div>
</div>
{/* Input area */}
<div className="bg-white dark:bg-gray-900 border-t p-4">
<div className="flex gap-2 max-w-3xl mx-auto">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Ask about your farm or crops..."
className="flex-1"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<Button
onClick={() => handleSendMessage()}
disabled={!inputValue.trim() || isLoading}
className="bg-green-600 hover:bg-green-700 text-white">
<Send className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* Chat history sidebar */}
{isHistoryOpen && (
<div className="w-80 border-l bg-white dark:bg-gray-900 flex flex-col">
<div className="p-4 border-b flex items-center justify-between">
<h2 className="font-medium flex items-center gap-1">
<History className="h-4 w-4" />
Chat History
</h2>
<Button variant="ghost" size="icon" onClick={() => setIsHistoryOpen(false)}>
<X className="h-4 w-4" />
</Button>
</div>
<div className="p-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-500 dark:text-gray-400" />
<Input placeholder="Search conversations..." className="pl-9" />
</div> </div>
</div> </div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
<Tabs defaultValue="recent" className="flex-1 flex flex-col"> {/* Recommended prompts */}
<TabsList className="mx-3 mb-2"> <div className="bg-white dark:bg-gray-900 border-t p-4">
<TabsTrigger value="recent" className="flex-1"> <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
Recent <Sparkles className="h-4 w-4 text-green-500" />
</TabsTrigger> Try asking...
<TabsTrigger value="farms" className="flex-1"> </h3>
By Farm <div className="flex flex-wrap gap-2">
</TabsTrigger> {recommendedPrompts.slice(0, 5).map(
<TabsTrigger value="crops" className="flex-1"> (
By Crop prompt // Limit displayed prompts
</TabsTrigger> ) => (
</TabsList> <Button
key={prompt.id}
variant="outline"
size="sm"
className="text-xs rounded-full bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 hover:bg-green-100 dark:hover:bg-green-900/40 text-green-800 dark:text-green-300"
onClick={() => handlePromptClick(prompt.text)}>
{prompt.text}
</Button>
)
)}
</div>
</div>
<ScrollArea className="flex-1"> {/* Input area */}
<TabsContent value="recent" className="m-0 p-0"> <div className="bg-white dark:bg-gray-900 border-t p-4 sticky bottom-0">
<div className="space-y-1 p-2"> <form
{mockChatHistory onSubmit={(e) => {
.filter((msg) => msg.sender === "user") e.preventDefault();
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()) handleSendMessage();
.map((message) => ( }}
<Card className="flex gap-2 max-w-4xl mx-auto">
key={message.id} <Input
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50" value={inputValue}
onClick={() => handleLoadChatHistory(message.id)}> onChange={(e) => setInputValue(e.target.value)}
<div className="flex items-start gap-3"> placeholder="Ask the farming assistant..."
<Avatar className="h-8 w-8 bg-green-100 dark:bg-green-900"> className="flex-1 h-11"
<div className="text-xs font-medium text-green-700 dark:text-green-300"> disabled={isLoading}
{message.relatedTo?.name.substring(0, 2) || "Me"} onKeyDown={(e) => {
</div> if (e.key === "Enter" && !e.shiftKey) {
</Avatar> e.preventDefault();
<div className="flex-1 min-w-0"> handleSendMessage();
<div className="flex items-center justify-between"> }
<p className="text-sm font-medium truncate"> }}
{message.relatedTo ? message.relatedTo.name : "General Question"} />
</p> <Button
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> type="submit"
<Clock className="h-3 w-3" /> disabled={!inputValue.trim() || isLoading}
{message.timestamp.toLocaleDateString()} className="bg-green-600 hover:bg-green-700 text-white h-11 px-5">
</p> {isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</div> <span className="sr-only">Send</span>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate mt-1"> </Button>
{message.content} </form>
</p>
</div>
</div>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="farms" className="m-0 p-0">
<div className="space-y-3 p-3">
{mockFarms.map((farm) => (
<div key={farm.id}>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{farm.name}</h3>
<div className="space-y-1">
{mockChatHistory
.filter(
(msg) =>
msg.sender === "user" && msg.relatedTo?.type === "farm" && msg.relatedTo.id === farm.id
)
.map((message) => (
<Card
key={message.id}
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
onClick={() => handleLoadChatHistory(message.id)}>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{message.content}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{message.timestamp.toLocaleDateString()}
</p>
</Card>
))}
</div>
<Separator className="my-3" />
</div>
))}
</div>
</TabsContent>
<TabsContent value="crops" className="m-0 p-0">
<div className="space-y-3 p-3">
{mockCrops.map((crop) => (
<div key={crop.id}>
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-1">
<Leaf className="h-3 w-3 text-green-600" />
{crop.name}
<span className="text-xs text-gray-500 dark:text-gray-400">
({mockFarms.find((f) => f.id === crop.farmId)?.name})
</span>
</h3>
<div className="space-y-1">
{mockChatHistory
.filter(
(msg) =>
msg.sender === "user" && msg.relatedTo?.type === "crop" && msg.relatedTo.id === crop.id
)
.map((message) => (
<Card
key={message.id}
className="p-3 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50"
onClick={() => handleLoadChatHistory(message.id)}>
<p className="text-xs text-gray-600 dark:text-gray-300 truncate">{message.content}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{message.timestamp.toLocaleDateString()}
</p>
</Card>
))}
</div>
<Separator className="my-3" />
</div>
))}
</div>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -36,16 +36,17 @@ import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-wi
interface CropDialogProps { interface CropDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSubmit: (data: Partial<Cropland>) => Promise<void>; onSubmit: (data: Partial<Omit<Cropland, "uuid" | "farmId">>) => Promise<void>;
isSubmitting: boolean; isSubmitting: boolean;
initialData?: Cropland | null;
isEditing?: boolean;
} }
export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropDialogProps) { export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting, initialData, isEditing }: CropDialogProps) {
// --- State --- // --- State ---
const [selectedPlantUUID, setSelectedPlantUUID] = useState<string | null>(null); const [selectedPlantUUID, setSelectedPlantUUID] = useState<string | null>(null);
// State to hold the structured GeoFeature data
const [geoFeature, setGeoFeature] = useState<GeoFeatureData | null>(null); const [geoFeature, setGeoFeature] = useState<GeoFeatureData | null>(null);
const [calculatedArea, setCalculatedArea] = useState<number | null>(null); // Keep for display const [calculatedArea, setCalculatedArea] = useState<number | null>(null);
// --- Load Google Maps Geometry Library --- // --- Load Google Maps Geometry Library ---
const geometryLib = useMapsLibrary("geometry"); const geometryLib = useMapsLibrary("geometry");
@ -63,6 +64,7 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const plants = useMemo(() => plantData?.plants || [], [plantData]); const plants = useMemo(() => plantData?.plants || [], [plantData]);
const selectedPlant = useMemo(() => { const selectedPlant = useMemo(() => {
return plants.find((p) => p.uuid === selectedPlantUUID); return plants.find((p) => p.uuid === selectedPlantUUID);
}, [plants, selectedPlantUUID]); }, [plants, selectedPlantUUID]);
@ -71,10 +73,14 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {
setSelectedPlantUUID(null); setSelectedPlantUUID(null);
setGeoFeature(null); // Reset geoFeature state setGeoFeature(null);
setCalculatedArea(null); setCalculatedArea(null);
} else if (initialData) {
setSelectedPlantUUID(initialData.plantId);
setGeoFeature(initialData.geoFeature ?? null);
setCalculatedArea(initialData.landSize ?? null);
} }
}, [open]); }, [open, initialData]);
// --- Map Interaction Handler --- // --- Map Interaction Handler ---
const handleShapeDrawn = useCallback( const handleShapeDrawn = useCallback(
@ -169,9 +175,13 @@ export function CropDialog({ open, onOpenChange, onSubmit, isSubmitting }: CropD
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[950px] md:max-w-[1100px] lg:max-w-[1200px] xl:max-w-7xl p-0 max-h-[90vh] flex flex-col"> <DialogContent className="sm:max-w-[950px] md:max-w-[1100px] lg:max-w-[1200px] xl:max-w-7xl p-0 max-h-[90vh] flex flex-col">
<DialogHeader className="p-6 pb-0"> <DialogHeader className="p-6 pb-0">
<DialogTitle className="text-xl font-semibold">Create New Cropland</DialogTitle> <DialogTitle className="text-xl font-semibold">
{isEditing ? "Edit Cropland" : "Create New Cropland"}
</DialogTitle>
<DialogDescription> <DialogDescription>
Select a plant and draw the cropland boundary or mark its location on the map. {isEditing
? "Update the cropland details and location."
: "Select a plant and draw the cropland boundary or mark its location on the map."}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>

View File

@ -1,16 +1,21 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Send } from "lucide-react"; import { Send, Bot, Loader2, User } from "lucide-react";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useParams } from "next/navigation";
import { sendChatMessage } from "@/api/chat";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
interface Message { interface Message {
id: string;
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
timestamp: Date;
} }
interface ChatbotDialogProps { interface ChatbotDialogProps {
@ -20,52 +25,158 @@ interface ChatbotDialogProps {
} }
export function ChatbotDialog({ open, onOpenChange, cropName }: ChatbotDialogProps) { export function ChatbotDialog({ open, onOpenChange, cropName }: ChatbotDialogProps) {
const [messages, setMessages] = useState<Message[]>([ const params = useParams<{ farmId: string; cropId: string }>();
{ const { farmId, cropId } = params;
role: "assistant",
content: `Hello! I'm your farming assistant. How can I help you with your ${cropName} today?`, const [messages, setMessages] = useState<Message[]>([]);
},
]);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false); // Loading state for API call
const messagesEndRef = useRef<HTMLDivElement>(null);
const handleSend = () => { // Initialize with a welcome message when dialog opens
if (!input.trim()) return; useEffect(() => {
if (open) {
setMessages([
{
id: `assistant-${Date.now()}`,
role: "assistant",
content: `Hello! How can I help you with ${cropName} (Crop ID: ${cropId}) today?`,
timestamp: new Date(),
},
]);
// Reset input when opening
setInput("");
setIsLoading(false);
}
}, [open, cropName, cropId]); // Add dependencies
const newMessages: Message[] = [ // Scroll to bottom of messages
...messages, useEffect(() => {
{ role: "user", content: input }, messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
{ role: "assistant", content: `Here's some information about ${cropName}: [AI response placeholder]` }, }, [messages, isLoading]); // Add isLoading to scroll when loading appears/disappears
];
setMessages(newMessages); const handleSend = async () => {
const messageText = input.trim();
if (!messageText || isLoading) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: "user",
content: messageText,
timestamp: new Date(),
};
// Add user message immediately and clear input, set loading
setMessages((prev) => [...prev, userMessage]);
setInput(""); setInput("");
setIsLoading(true); // Show loading indicator *now*
// Prepare history for API call (use messages *before* adding the placeholder)
const apiHistory = [...messages, userMessage] // Include the just-added user message
.map((msg) => ({ role: msg.role, text: msg.content }));
try {
// Call the API function with context
const response = await sendChatMessage(
userMessage.content,
apiHistory,
farmId, // Pass farmId
cropId // Pass cropId
);
const assistantMessage: Message = {
id: `assistant-${Date.now()}`,
role: "assistant",
content: response.response,
timestamp: new Date(),
};
// Replace loading state with the actual response
setMessages((prev) => [...prev, assistantMessage]);
} catch (error) {
console.error("Error sending chat message:", error);
const errorMessage: Message = {
id: `error-${Date.now()}`,
role: "assistant",
content: `Sorry, something went wrong. ${(error as Error).message || "Please try again."}`,
timestamp: new Date(),
};
// Replace loading state with the error message
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false); // Hide loading indicator
}
}; };
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<VisuallyHidden> <VisuallyHidden>
<DialogTitle>Farming Assistant Chat</DialogTitle> <DialogTitle>Farming Assistant Chat for {cropName}</DialogTitle>
</VisuallyHidden> </VisuallyHidden>
<DialogContent className="sm:max-w-[500px] p-0 dark:bg-background"> <DialogContent className="sm:max-w-[500px] p-0 dark:bg-background">
<div className="flex flex-col h-[600px]"> <div className="flex flex-col h-[600px]">
<div className="p-4 border-b dark:border-slate-700"> <div className="p-4 border-b dark:border-slate-700">
<h2 className="text-lg font-semibold">Farming Assistant</h2> <h2 className="text-lg font-semibold flex items-center gap-2">
<p className="text-sm text-muted-foreground">Ask questions about your {cropName}</p> <Bot className="h-5 w-5 text-green-600" />
Farming Assistant
</h2>
<p className="text-sm text-muted-foreground">Ask about {cropName}</p>
</div> </div>
<ScrollArea className="flex-1 p-4"> <ScrollArea className="flex-1 p-4">
<div className="space-y-4"> <div className="space-y-4">
{messages.map((message, i) => ( {messages.map(
<div key={i} className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}> (
message // Render existing messages
) => (
<div <div
className={`rounded-lg px-4 py-2 max-w-[80%] ${ key={message.id}
message.role === "user" className={`flex items-start gap-3 ${message.role === "user" ? "justify-end" : ""}`}>
? "bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground" {message.role === "assistant" && (
: "bg-muted dark:bg-muted dark:text-muted-foreground" <Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0 shadow-sm">
}`}> <AvatarFallback>
{message.content} <Bot className="h-5 w-5 text-green-600" />
</AvatarFallback>
</Avatar>
)}
<div
className={`rounded-lg px-4 py-2 max-w-[80%] shadow-sm ${
message.role === "user"
? "bg-primary text-primary-foreground dark:bg-primary dark:text-primary-foreground"
: "bg-muted dark:bg-muted dark:text-muted-foreground"
}`}>
<p className="text-sm whitespace-pre-wrap break-words">{message.content}</p>
<p
className={`mt-1 text-xs opacity-70 ${
message.role === "user" ? "text-green-100" : "text-gray-500 dark:text-gray-400"
} text-right`}>
{message.timestamp.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</p>
</div>
{message.role === "user" && (
<Avatar className="h-8 w-8 border bg-gray-200 dark:bg-gray-600 flex-shrink-0 shadow-sm">
<AvatarFallback>
<User className="h-5 w-5 text-gray-600 dark:text-gray-300" />
</AvatarFallback>
</Avatar>
)}
</div>
)
)}
{/* Conditional Loading Indicator */}
{isLoading && (
<div className="flex items-start gap-3">
<Avatar className="h-8 w-8 border bg-white dark:bg-gray-700 flex-shrink-0">
<AvatarFallback>
<Bot className="h-5 w-5 text-green-600" />
</AvatarFallback>
</Avatar>
<div className="flex items-center gap-2 p-2 rounded-lg bg-muted dark:bg-muted">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Assistant is thinking...</span>
</div> </div>
</div> </div>
))} )}
<div ref={messagesEndRef} />
</div> </div>
</ScrollArea> </ScrollArea>
@ -76,9 +187,22 @@ export function ChatbotDialog({ open, onOpenChange, cropName }: ChatbotDialogPro
handleSend(); handleSend();
}} }}
className="flex gap-2"> className="flex gap-2">
<Input placeholder="Type your message..." value={input} onChange={(e) => setInput(e.target.value)} /> <Input
<Button type="submit" size="icon"> placeholder="Type your message..."
<Send className="h-4 w-4" /> value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
className="h-11"
/>
<Button type="submit" size="icon" disabled={isLoading || !input.trim()} className="h-11 w-11">
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
<span className="sr-only">Send</span>
</Button> </Button>
</form> </form>
</div> </div>

View File

@ -2,7 +2,7 @@
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { useRouter, useParams } from "next/navigation"; import { useRouter, useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowLeft,
LineChart, LineChart,
@ -11,7 +11,6 @@ import {
Sun, Sun,
ThermometerSun, ThermometerSun,
Timer, Timer,
ListCollapse,
Leaf, Leaf,
CloudRain, CloudRain,
Wind, Wind,
@ -22,6 +21,7 @@ import {
LeafIcon, LeafIcon,
History, History,
Bot, Bot,
MoreHorizontal,
} from "lucide-react"; } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -35,16 +35,42 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import type { Cropland, CropAnalytics, Farm } from "@/types"; import type { Cropland, CropAnalytics, Farm } from "@/types";
import { getFarm } from "@/api/farm"; import { getFarm } from "@/api/farm";
import { getPlants, PlantResponse } from "@/api/plant"; import { getPlants, PlantResponse } from "@/api/plant";
import { getCropById, fetchCropAnalytics } from "@/api/crop"; // Import the updated API functions
import { getCropById, fetchCropAnalytics, deleteCrop, updateCrop } from "@/api/crop";
import GoogleMapWithDrawing from "@/components/google-map-with-drawing"; import GoogleMapWithDrawing from "@/components/google-map-with-drawing";
import { toast } from "sonner";
import { CropDialog } from "../../crop-dialog"; // Assuming CropDialog is in the parent directory
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
// Define the expected shape of data coming from CropDialog for update
// Excludes fields not sent in the PUT request body (uuid, farmId, createdAt, updatedAt)
type CropUpdateData = Omit<Cropland, "uuid" | "farmId" | "createdAt" | "updatedAt">;
export default function CropDetailPage() { export default function CropDetailPage() {
const router = useRouter(); const router = useRouter();
const params = useParams<{ farmId: string; cropId: string }>(); const params = useParams<{ farmId: string; cropId: string }>();
const { farmId, cropId } = params; const { farmId, cropId } = params;
const queryClient = useQueryClient();
const [isChatOpen, setIsChatOpen] = useState(false); const [isChatOpen, setIsChatOpen] = useState(false);
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false); const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const [isEditCropOpen, setIsEditCropOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
// --- Fetch Farm Data --- // --- Fetch Farm Data ---
const { data: farm, isLoading: isLoadingFarm } = useQuery<Farm>({ const { data: farm, isLoading: isLoadingFarm } = useQuery<Farm>({
@ -64,7 +90,7 @@ export default function CropDetailPage() {
queryKey: ["crop", cropId], queryKey: ["crop", cropId],
queryFn: () => getCropById(cropId), queryFn: () => getCropById(cropId),
enabled: !!cropId, enabled: !!cropId,
staleTime: 60 * 1000, staleTime: 60 * 1000, // Refetch more often than farm/plants
}); });
// --- Fetch All Plants Data --- // --- Fetch All Plants Data ---
@ -76,7 +102,7 @@ export default function CropDetailPage() {
} = useQuery<PlantResponse>({ } = useQuery<PlantResponse>({
queryKey: ["plants"], queryKey: ["plants"],
queryFn: getPlants, queryFn: getPlants,
staleTime: 1000 * 60 * 60, staleTime: 1000 * 60 * 60, // Plants data is relatively static
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
@ -88,7 +114,7 @@ export default function CropDetailPage() {
// --- Fetch Crop Analytics Data --- // --- Fetch Crop Analytics Data ---
const { const {
data: analytics, // Type is CropAnalytics | null data: analytics,
isLoading: isLoadingAnalytics, isLoading: isLoadingAnalytics,
isError: isErrorAnalytics, isError: isErrorAnalytics,
error: errorAnalytics, error: errorAnalytics,
@ -99,9 +125,66 @@ export default function CropDetailPage() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
// --- Delete Crop Mutation ---
const deleteMutation = useMutation({
mutationFn: () => deleteCrop(cropId), // Uses DELETE /crop/{cropId}
onSuccess: () => {
toast.success(`Crop "${cropland?.name}" deleted successfully.`);
queryClient.invalidateQueries({ queryKey: ["crops", farmId] });
queryClient.invalidateQueries({ queryKey: ["farm", farmId] });
queryClient.removeQueries({ queryKey: ["crop", cropId] });
queryClient.removeQueries({ queryKey: ["cropAnalytics", cropId] });
router.push(`/farms/${farmId}`);
},
onError: (error) => {
console.error("Failed to delete crop:", error);
toast.error(`Failed to delete crop: ${(error as Error).message}`);
},
onSettled: () => {
setIsDeleteDialogOpen(false);
},
});
// --- Update Crop Mutation ---
// Updated to use the new updateCrop signature: updateCrop(cropId, payload)
const updateMutation = useMutation({
// dataFromDialog should contain the fields needed for the PUT request body
mutationFn: async (dataFromDialog: CropUpdateData) => {
if (!cropId) {
throw new Error("Crop ID is missing for update.");
}
// Prepare the payload matching the UpdateCroplandInput body structure
// Ensure all required fields for the PUT endpoint are present
const updatePayload = {
name: dataFromDialog.name,
status: dataFromDialog.status,
priority: dataFromDialog.priority ?? 0, // Use default or ensure it comes from dialog
landSize: dataFromDialog.landSize ?? 0, // Use default or ensure it comes from dialog
growthStage: dataFromDialog.growthStage,
plantId: dataFromDialog.plantId,
geoFeature: dataFromDialog.geoFeature,
};
// Call the API function with cropId and the prepared payload
return updateCrop(cropId, updatePayload);
},
onSuccess: (updatedCrop) => {
toast.success(`Crop "${updatedCrop.name}" updated successfully.`);
// Invalidate queries to refetch data
queryClient.invalidateQueries({ queryKey: ["crop", cropId] });
queryClient.invalidateQueries({ queryKey: ["crops", farmId] }); // Update list on farm page if name changed
queryClient.invalidateQueries({ queryKey: ["farm", farmId] }); // Update farm details if needed
queryClient.invalidateQueries({ queryKey: ["cropAnalytics", cropId] });
setIsEditCropOpen(false); // Close the edit dialog
},
onError: (error) => {
console.error("Failed to update crop:", error);
toast.error(`Failed to update crop: ${(error as Error).message}`);
},
});
// --- Combined Loading and Error States --- // --- Combined Loading and Error States ---
const isLoading = isLoadingFarm || isLoadingCropland || isLoadingPlants || isLoadingAnalytics; const isLoading = isLoadingFarm || isLoadingCropland || isLoadingPlants || isLoadingAnalytics;
const isError = isErrorCropland || isErrorPlants || isErrorAnalytics; // Prioritize crop/analytics errors const isError = isErrorCropland || isErrorPlants || isErrorAnalytics;
const error = errorCropland || errorPlants || errorAnalytics; const error = errorCropland || errorPlants || errorAnalytics;
// --- Loading State --- // --- Loading State ---
@ -117,6 +200,9 @@ export default function CropDetailPage() {
// --- Error State --- // --- Error State ---
if (isError || !cropland) { if (isError || !cropland) {
console.error("Error loading crop details:", error); console.error("Error loading crop details:", error);
const errorMessage = isErrorCropland
? `Crop with ID ${cropId} not found or could not be loaded.`
: (error as Error)?.message || "An unexpected error occurred.";
return ( return (
<div className="min-h-screen container max-w-7xl p-6 mx-auto"> <div className="min-h-screen container max-w-7xl p-6 mx-auto">
<Button <Button
@ -129,11 +215,7 @@ export default function CropDetailPage() {
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Error Loading Crop Details</AlertTitle> <AlertTitle>Error Loading Crop Details</AlertTitle>
<AlertDescription> <AlertDescription>{errorMessage}</AlertDescription>
{isErrorCropland
? `Crop with ID ${cropId} not found or could not be loaded.`
: (error as Error)?.message || "An unexpected error occurred."}
</AlertDescription>
</Alert> </Alert>
</div> </div>
); );
@ -144,8 +226,11 @@ export default function CropDetailPage() {
good: "text-green-500 bg-green-50 dark:bg-green-900 border-green-200", good: "text-green-500 bg-green-50 dark:bg-green-900 border-green-200",
warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900 border-yellow-200", warning: "text-yellow-500 bg-yellow-50 dark:bg-yellow-900 border-yellow-200",
critical: "text-red-500 bg-red-50 dark:bg-red-900 border-red-200", critical: "text-red-500 bg-red-50 dark:bg-red-900 border-red-200",
unknown: "text-gray-500 bg-gray-50 dark:bg-gray-900 border-gray-200", // Added for safety
}; };
const healthStatus = analytics?.plantHealth || "good"; // Use a safe default if analytics or plantHealth is missing
const healthStatus = (analytics?.plantHealth as keyof typeof healthColors) || "unknown";
const healthColorClass = healthColors[healthStatus] || healthColors.unknown;
const quickActions = [ const quickActions = [
{ {
@ -154,6 +239,7 @@ export default function CropDetailPage() {
description: "View detailed growth analytics", description: "View detailed growth analytics",
onClick: () => setIsAnalyticsOpen(true), onClick: () => setIsAnalyticsOpen(true),
color: "bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300", color: "bg-blue-50 dark:bg-blue-900 text-blue-600 dark:text-blue-300",
disabled: !analytics, // Disable if no analytics data
}, },
{ {
title: "Chat Assistant", title: "Chat Assistant",
@ -162,35 +248,24 @@ export default function CropDetailPage() {
onClick: () => setIsChatOpen(true), onClick: () => setIsChatOpen(true),
color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300", color: "bg-green-50 dark:bg-green-900 text-green-600 dark:text-green-300",
}, },
{ // Settings moved to dropdown
title: "Crop Details",
icon: ListCollapse,
description: "View detailed information",
onClick: () => console.log("Details clicked - Placeholder"),
color: "bg-purple-50 dark:bg-purple-900 text-purple-600 dark:text-purple-300",
},
{
title: "Settings",
icon: Settings,
description: "Configure crop settings",
onClick: () => console.log("Settings clicked - Placeholder"),
color: "bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300",
},
]; ];
const plantedDate = cropland.createdAt ? new Date(cropland.createdAt) : null; const plantedDate = cropland.createdAt ? new Date(cropland.createdAt) : null;
const daysToMaturity = plant?.daysToMaturity; // Use camelCase const daysToMaturity = plant?.daysToMaturity;
const expectedHarvestDate = const expectedHarvestDate =
plantedDate && daysToMaturity ? new Date(plantedDate.getTime() + daysToMaturity * 24 * 60 * 60 * 1000) : null; plantedDate && typeof daysToMaturity === "number"
? new Date(plantedDate.getTime() + daysToMaturity * 24 * 60 * 60 * 1000)
: null;
const growthProgress = analytics?.growthProgress ?? 0; // Get from analytics const growthProgress = analytics?.growthProgress ?? 0;
const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize} ha` : "N/A"; // Use camelCase const displayArea = typeof cropland.landSize === "number" ? `${cropland.landSize.toFixed(2)} ha` : "N/A";
return ( return (
<div className="min-h-screen bg-background text-foreground"> <div className="min-h-screen bg-background text-foreground">
<div className="container max-w-7xl p-6 mx-auto"> <div className="container max-w-7xl p-6 mx-auto">
{/* Breadcrumbs */} {/* Breadcrumbs */}
<nav className="flex items-center text-sm text-muted-foreground mb-4"> <nav className="flex items-center text-sm text-muted-foreground mb-4 flex-wrap">
<Button <Button
variant="link" variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary" className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
@ -198,22 +273,25 @@ export default function CropDetailPage() {
<Home className="h-3.5 w-3.5 mr-1" /> <Home className="h-3.5 w-3.5 mr-1" />
Home Home
</Button> </Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" /> <ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
<Button <Button
variant="link" variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary" className="p-0 h-auto font-normal text-muted-foreground hover:text-primary"
onClick={() => router.push("/farms")}> onClick={() => router.push("/farms")}>
Farms Farms
</Button> </Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" /> <ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
<Button <Button
variant="link" variant="link"
className="p-0 h-auto font-normal text-muted-foreground hover:text-primary max-w-[150px] truncate" className="p-0 h-auto font-normal text-muted-foreground hover:text-primary max-w-[150px] truncate"
title={farm?.name || "Farm"}
onClick={() => router.push(`/farms/${farmId}`)}> onClick={() => router.push(`/farms/${farmId}`)}>
{farm?.name || "Farm"} {/* Use camelCase */} {farm?.name || "Farm"}
</Button> </Button>
<ChevronRight className="h-3.5 w-3.5 mx-1" /> <ChevronRight className="h-3.5 w-3.5 mx-1 flex-shrink-0" />
<span className="text-foreground font-medium truncate">{cropland.name || "Crop"}</span> {/* Use camelCase */} <span className="text-foreground font-medium truncate" title={cropland.name || "Crop"}>
{cropland.name || "Crop"}
</span>
</nav> </nav>
{/* Header */} {/* Header */}
@ -226,21 +304,40 @@ export default function CropDetailPage() {
onClick={() => router.push(`/farms/${farmId}`)}> onClick={() => router.push(`/farms/${farmId}`)}>
<ArrowLeft className="h-4 w-4" /> Back to Farm <ArrowLeft className="h-4 w-4" /> Back to Farm
</Button> </Button>
{/* Hover Card (removed for simplicity, add back if needed) */} <DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Crop Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setIsEditCropOpen(true)}>
<Settings className="mr-2 h-4 w-4" />
<span>Edit Crop</span>
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:bg-red-50 focus:text-red-700"
onClick={() => setIsDeleteDialogOpen(true)}>
<AlertTriangle className="mr-2 h-4 w-4" />
<span>Delete Crop</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
<div className="flex flex-col md:flex-row justify-between gap-4"> <div className="flex flex-col md:flex-row justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1> {/* Use camelCase */} <h1 className="text-3xl font-bold tracking-tight">{cropland.name}</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{plant?.variety || "Unknown Variety"} {displayArea} {/* Use camelCase */} {plant?.variety || "Unknown Variety"} {displayArea}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex flex-col items-end"> <div className="flex flex-col items-end">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className={`${healthColors[healthStatus]} border capitalize`}> <Badge variant="outline" className={`${healthColorClass} border capitalize`}>
{cropland.status} {/* Use camelCase */} {cropland.status}
</Badge> </Badge>
</div> </div>
{expectedHarvestDate ? ( {expectedHarvestDate ? (
@ -260,23 +357,28 @@ export default function CropDetailPage() {
{/* Left Column */} {/* Left Column */}
<div className="md:col-span-8 space-y-6"> <div className="md:col-span-8 space-y-6">
{/* Quick Actions */} {/* Quick Actions */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid sm:grid-cols-2 gap-4">
{quickActions.map((action) => ( {quickActions.map((action) => (
<Button <Button
key={action.title} key={action.title}
variant="outline" variant="outline"
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${action.color} hover:scale-105 border-border/30`} disabled={action.disabled}
className={`h-auto p-4 flex flex-col items-center gap-3 transition-all group ${
action.disabled ? "opacity-50 cursor-not-allowed" : `${action.color} hover:scale-105`
} border-border/30`}
onClick={action.onClick}> onClick={action.onClick}>
<div <div
className={`p-3 rounded-lg ${action.color.replace( className={`p-3 rounded-lg ${
"text-", action.disabled ? "bg-muted" : `${action.color.replace("text-", "bg-")}/20`
"bg-" } group-hover:scale-110 transition-transform`}>
)}/20 group-hover:scale-110 transition-transform`}>
<action.icon className="h-5 w-5" /> <action.icon className="h-5 w-5" />
</div> </div>
<div className="text-center"> <div className="text-center">
<div className="font-medium mb-1">{action.title}</div> <div className="font-medium mb-1">{action.title}</div>
<p className="text-xs text-muted-foreground">{action.description}</p> <p className="text-xs text-muted-foreground">{action.description}</p>
{action.disabled && action.title === "Analytics" && (
<p className="text-xs text-amber-600 mt-1">(No data)</p>
)}
</div> </div>
</Button> </Button>
))} ))}
@ -286,51 +388,70 @@ export default function CropDetailPage() {
<Card className="border-border/30"> <Card className="border-border/30">
<CardHeader> <CardHeader>
<CardTitle>Environmental Conditions</CardTitle> <CardTitle>Environmental Conditions</CardTitle>
<CardDescription>Real-time monitoring data</CardDescription> <CardDescription>Real-time monitoring data (if available)</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-6"> <div className="grid gap-6">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{[ {[
// ... (metric definitions remain the same)
{ {
icon: ThermometerSun, icon: ThermometerSun,
label: "Temperature", label: "Temperature",
value: analytics?.temperature ? `${analytics.temperature}°C` : "N/A", value:
analytics?.temperature !== null && analytics?.temperature !== undefined
? `${analytics.temperature.toFixed(1)}°C`
: "N/A",
color: "text-orange-500 dark:text-orange-300", color: "text-orange-500 dark:text-orange-300",
bg: "bg-orange-50 dark:bg-orange-900", bg: "bg-orange-50 dark:bg-orange-900",
}, },
{ {
icon: Droplets, icon: Droplets,
label: "Humidity", label: "Humidity",
value: analytics?.humidity ? `${analytics.humidity}%` : "N/A", value:
analytics?.humidity !== null && analytics?.humidity !== undefined
? `${analytics.humidity.toFixed(0)}%`
: "N/A",
color: "text-blue-500 dark:text-blue-300", color: "text-blue-500 dark:text-blue-300",
bg: "bg-blue-50 dark:bg-blue-900", bg: "bg-blue-50 dark:bg-blue-900",
}, },
{ {
icon: Sun, icon: Sun,
label: "Sunlight", label: "Sunlight",
value: analytics?.sunlight ? `${analytics.sunlight}%` : "N/A", value:
analytics?.sunlight !== null && analytics?.sunlight !== undefined
? `${analytics.sunlight.toFixed(0)}%`
: "N/A",
color: "text-yellow-500 dark:text-yellow-300", color: "text-yellow-500 dark:text-yellow-300",
bg: "bg-yellow-50 dark:bg-yellow-900", bg: "bg-yellow-50 dark:bg-yellow-900",
}, },
{ {
icon: Leaf, icon: Leaf,
label: "Soil Moisture", label: "Soil Moisture",
value: analytics?.soilMoisture ? `${analytics.soilMoisture}%` : "N/A", value:
analytics?.soilMoisture !== null && analytics?.soilMoisture !== undefined
? `${analytics.soilMoisture.toFixed(0)}%`
: "N/A",
color: "text-green-500 dark:text-green-300", color: "text-green-500 dark:text-green-300",
bg: "bg-green-50 dark:bg-green-900", bg: "bg-green-50 dark:bg-green-900",
}, },
{ {
icon: Wind, icon: Wind,
label: "Wind Speed", label: "Wind Speed",
value: analytics?.windSpeed ?? "N/A", value:
analytics?.windSpeed !== null && analytics?.windSpeed !== undefined
? `${analytics.windSpeed.toFixed(1)} m/s`
: "N/A",
color: "text-gray-500 dark:text-gray-300", color: "text-gray-500 dark:text-gray-300",
bg: "bg-gray-50 dark:bg-gray-900", bg: "bg-gray-50 dark:bg-gray-900",
}, },
{ {
icon: CloudRain, icon: CloudRain,
label: "Rainfall", label: "Rainfall (1h)",
value: analytics?.rainfall ?? "N/A", value:
analytics?.rainfall !== null && analytics?.rainfall !== undefined
? `${analytics.rainfall.toFixed(1)} mm`
: "N/A",
color: "text-indigo-500 dark:text-indigo-300", color: "text-indigo-500 dark:text-indigo-300",
bg: "bg-indigo-50 dark:bg-indigo-900", bg: "bg-indigo-50 dark:bg-indigo-900",
}, },
@ -350,42 +471,50 @@ export default function CropDetailPage() {
</Card> </Card>
))} ))}
</div> </div>
<Separator /> {/* Show message if no analytics at all */}
{/* Growth Progress */} {!analytics && !isLoadingAnalytics && (
<div className="space-y-2"> <p className="text-center text-sm text-muted-foreground py-4">Environmental data not available.</p>
<div className="flex justify-between text-sm"> )}
<span className="font-medium">Growth Progress</span> {analytics && (
<span className="text-muted-foreground">{growthProgress}%</span> <>
</div> <Separator />
<Progress value={growthProgress} className="h-2" /> {/* Growth Progress */}
<p className="text-xs text-muted-foreground"> <div className="space-y-2">
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity. <div className="flex justify-between text-sm">
</p> <span className="font-medium">Growth Progress</span>
</div> <span className="text-muted-foreground">{growthProgress}%</span>
{/* Next Action Card */}
<Card className="border-blue-100 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/50">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-800">
<Timer className="h-4 w-4 text-blue-600 dark:text-blue-300" />
</div>
<div>
<p className="font-medium mb-1">Next Action Required</p>
<p className="text-sm text-muted-foreground">
{analytics?.nextAction || "Check crop status"}
</p>
{analytics?.nextActionDue && (
<p className="text-xs text-muted-foreground mt-1">
Due by {new Date(analytics.nextActionDue).toLocaleDateString()}
</p>
)}
{!analytics?.nextAction && (
<p className="text-xs text-muted-foreground mt-1">No immediate actions required.</p>
)}
</div> </div>
<Progress value={growthProgress} className="h-2" />
<p className="text-xs text-muted-foreground">
Based on {daysToMaturity ? `${daysToMaturity} days` : "N/A"} to maturity.
</p>
</div> </div>
</CardContent> {/* Next Action Card */}
</Card> <Card className="border-blue-100 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-900/50">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-800">
<Timer className="h-4 w-4 text-blue-600 dark:text-blue-300" />
</div>
<div>
<p className="font-medium mb-1">Next Action Required</p>
<p className="text-sm text-muted-foreground">
{analytics?.nextAction || "Check crop status"}
</p>
{analytics?.nextActionDue && (
<p className="text-xs text-muted-foreground mt-1">
Due by {new Date(analytics.nextActionDue).toLocaleDateString()}
</p>
)}
{!analytics?.nextAction && (
<p className="text-xs text-muted-foreground mt-1">No immediate actions required.</p>
)}
</div>
</div>
</CardContent>
</Card>
</>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -397,11 +526,11 @@ export default function CropDetailPage() {
<CardDescription>Visual representation on the farm</CardDescription> <CardDescription>Visual representation on the farm</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="p-0 h-[400px] overflow-hidden rounded-b-lg"> <CardContent className="p-0 h-[400px] overflow-hidden rounded-b-lg">
{/* TODO: Ensure GoogleMapWithDrawing correctly parses and displays GeoFeatureData */}
<GoogleMapWithDrawing <GoogleMapWithDrawing
initialFeatures={cropland.geoFeature ? [cropland.geoFeature] : undefined} initialFeatures={cropland.geoFeature ? [cropland.geoFeature] : undefined}
drawingMode={null} initialCenter={farm ? { lat: farm.lat, lng: farm.lon } : undefined}
editable={false} initialZoom={15}
displayOnly={true}
/> />
</CardContent> </CardContent>
</Card> </Card>
@ -420,37 +549,39 @@ export default function CropDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{[ {/* Check if analytics and nutrientLevels exist before mapping */}
{ {analytics?.nutrientLevels ? (
name: "Nitrogen (N)", [
value: analytics?.nutrientLevels?.nitrogen, {
color: "bg-blue-500 dark:bg-blue-700", name: "Nitrogen (N)",
}, value: analytics.nutrientLevels.nitrogen,
{ color: "bg-blue-500 dark:bg-blue-700",
name: "Phosphorus (P)", },
value: analytics?.nutrientLevels?.phosphorus, {
color: "bg-yellow-500 dark:bg-yellow-700", name: "Phosphorus (P)",
}, value: analytics.nutrientLevels.phosphorus,
{ color: "bg-yellow-500 dark:bg-yellow-700",
name: "Potassium (K)", },
value: analytics?.nutrientLevels?.potassium, {
color: "bg-green-500 dark:bg-green-700", name: "Potassium (K)",
}, value: analytics.nutrientLevels.potassium,
].map((nutrient) => ( color: "bg-green-500 dark:bg-green-700",
<div key={nutrient.name} className="space-y-2"> },
<div className="flex justify-between text-sm"> ].map((nutrient) => (
<span className="font-medium">{nutrient.name}</span> <div key={nutrient.name} className="space-y-2">
<span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span> <div className="flex justify-between text-sm">
<span className="font-medium">{nutrient.name}</span>
<span className="text-muted-foreground">{nutrient.value ?? "N/A"}%</span>
</div>
<Progress
value={nutrient.value ?? 0}
className={`h-2 ${
nutrient.value !== null && nutrient.value !== undefined ? nutrient.color : "bg-muted"
}`}
/>
</div> </div>
<Progress ))
value={nutrient.value ?? 0} ) : (
className={`h-2 ${
nutrient.value !== null && nutrient.value !== undefined ? nutrient.color : "bg-muted"
}`}
/>
</div>
))}
{!analytics?.nutrientLevels && (
<p className="text-center text-sm text-muted-foreground py-4">Nutrient data not available.</p> <p className="text-center text-sm text-muted-foreground py-4">Nutrient data not available.</p>
)} )}
</div> </div>
@ -467,6 +598,7 @@ export default function CropDetailPage() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<ScrollArea className="h-[300px] pr-4"> <ScrollArea className="h-[300px] pr-4">
{/* Placeholder - Replace with actual activity log */}
<div className="text-center py-10 text-muted-foreground">No recent activity logged.</div> <div className="text-center py-10 text-muted-foreground">No recent activity logged.</div>
</ScrollArea> </ScrollArea>
</CardContent> </CardContent>
@ -476,28 +608,52 @@ export default function CropDetailPage() {
{/* Dialogs */} {/* Dialogs */}
<ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={cropland.name || "this crop"} /> <ChatbotDialog open={isChatOpen} onOpenChange={setIsChatOpen} cropName={cropland.name || "this crop"} />
{/* Ensure AnalyticsDialog uses the correct props */}
{/* Conditionally render AnalyticsDialog only if analytics data exists */}
{analytics && ( {analytics && (
<AnalyticsDialog <AnalyticsDialog
open={isAnalyticsOpen} open={isAnalyticsOpen}
onOpenChange={setIsAnalyticsOpen} onOpenChange={setIsAnalyticsOpen}
// The dialog expects a `Crop` type, but we have `Cropland` and `CropAnalytics` crop={cropland} // Pass the full cropland object
// We need to construct a simplified `Crop` object or update the dialog prop type analytics={analytics} // Pass the analytics data
crop={{
// Constructing a simplified Crop object
uuid: cropland.uuid,
farmId: cropland.farmId,
name: cropland.name,
createdAt: cropland.createdAt, // Use createdAt as plantedDate
status: cropland.status,
variety: plant?.variety, // Get from plant data
area: `${cropland.landSize} ha`, // Convert landSize
progress: growthProgress, // Use calculated/fetched progress
// healthScore might map to plantHealth
}}
analytics={analytics} // Pass fetched analytics
/> />
)} )}
{/* Edit Crop Dialog */}
<CropDialog
open={isEditCropOpen}
onOpenChange={setIsEditCropOpen}
initialData={cropland} // Pass current cropland data to pre-fill the form
onSubmit={async (data) => {
// 'data' from the dialog should match CropUpdateData structure
await updateMutation.mutateAsync(data as CropUpdateData);
}}
isSubmitting={updateMutation.isPending}
isEditing={true} // Indicate that this is for editing
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the crop &quot;{cropland.name}&quot; and all
associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteMutation.mutate()}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete Crop
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,242 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import * as z from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { useCallback, useEffect } from "react"; // Added useEffect
import { Loader2 } from "lucide-react";
import type { Farm } from "@/types";
import GoogleMapWithDrawing, { type ShapeData } from "@/components/google-map-with-drawing";
// Schema for editing - make fields optional if needed, but usually same as create
const farmFormSchema = z.object({
name: z.string().min(2, "Farm name must be at least 2 characters"),
latitude: z
.number({ invalid_type_error: "Latitude must be a number" })
.min(-90, "Invalid latitude")
.max(90, "Invalid latitude")
.refine((val) => val !== 0, { message: "Please select a location on the map." }),
longitude: z
.number({ invalid_type_error: "Longitude must be a number" })
.min(-180, "Invalid longitude")
.max(180, "Invalid longitude")
.refine((val) => val !== 0, { message: "Please select a location on the map." }),
type: z.string().min(1, "Please select a farm type"),
area: z.string().optional(),
});
export interface EditFarmFormProps {
initialData: Farm; // Require initial data for editing
onSubmit: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) => Promise<void>; // Exclude non-editable fields
onCancel: () => void;
isSubmitting: boolean;
}
export function EditFarmForm({ initialData, onSubmit, onCancel, isSubmitting }: EditFarmFormProps) {
const form = useForm<z.infer<typeof farmFormSchema>>({
resolver: zodResolver(farmFormSchema),
// Set default values from initialData
defaultValues: {
name: initialData.name || "",
latitude: initialData.lat || 0,
longitude: initialData.lon || 0,
type: initialData.farmType || "",
area: initialData.totalSize || "",
},
});
// Update form if initialData changes (e.g., opening dialog for different farms)
useEffect(() => {
form.reset({
name: initialData.name || "",
latitude: initialData.lat || 0,
longitude: initialData.lon || 0,
type: initialData.farmType || "",
area: initialData.totalSize || "",
});
}, [initialData, form.reset]);
const handleSubmit = async (values: z.infer<typeof farmFormSchema>) => {
try {
// Shape data for the API update function
const farmUpdateData: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">> = {
name: values.name,
lat: values.latitude,
lon: values.longitude,
farmType: values.type,
totalSize: values.area,
};
await onSubmit(farmUpdateData);
// No need to reset form here, dialog closing handles it or parent component does
} catch (error) {
console.error("Error submitting edit form:", error);
// Error handled by mutation's onError
}
};
// Map handler - same as AddFarmForm
const handleShapeDrawn = useCallback(
(data: ShapeData) => {
if (data.type === "marker") {
const { lat, lng } = data.position;
form.setValue("latitude", lat, { shouldValidate: true });
form.setValue("longitude", lng, { shouldValidate: true });
} else {
console.log(`Shape type '${data.type}' ignored for coordinate update.`);
}
},
[form]
);
return (
<div className="flex flex-col lg:flex-row gap-6 p-4">
{/* Form Section */}
<div className="lg:flex-[1]">
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
{/* Fields: Name, Lat/Lon, Type, Area - same structure as AddFarmForm */}
{/* Farm Name Field */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Farm Name</FormLabel>
<FormControl>
<Input placeholder="Enter farm name" {...field} />
</FormControl>
<FormDescription>This is your farm's display name.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Coordinate Fields (Latitude & Longitude) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<FormField
control={form.control}
name="latitude"
render={({ field }) => (
<FormItem>
<FormLabel>Latitude</FormLabel>
<FormControl>
<Input
placeholder="Select on map"
{...field}
value={field.value ? field.value.toFixed(6) : ""}
disabled
readOnly
className="disabled:opacity-100 disabled:cursor-default"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="longitude"
render={({ field }) => (
<FormItem>
<FormLabel>Longitude</FormLabel>
<FormControl>
<Input
placeholder="Select on map"
{...field}
value={field.value ? field.value.toFixed(6) : ""}
disabled
readOnly
className="disabled:opacity-100 disabled:cursor-default"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* Farm Type Selection */}
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Farm Type</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select farm type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="durian">Durian</SelectItem>
<SelectItem value="mango">Mango</SelectItem>
<SelectItem value="rice">Rice</SelectItem>
<SelectItem value="mixed">Mixed Crops</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{/* Total Area Field */}
<FormField
control={form.control}
name="area"
render={({ field }) => (
<FormItem>
<FormLabel>Total Area (optional)</FormLabel>
<FormControl>
<Input placeholder="e.g., 10 hectares" {...field} value={field.value ?? ""} />
</FormControl>
<FormDescription>The total size of your farm (e.g., "15 rai", "10 hectares").</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Submit and Cancel Buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onCancel} disabled={isSubmitting}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting} className="bg-blue-600 hover:bg-blue-700">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
"Save Changes"
)}
</Button>
</div>
</form>
</Form>
</div>
{/* Map Section */}
<div className="lg:flex-[2] min-h-[400px] lg:min-h-0 flex flex-col">
<FormLabel>Farm Location (Update marker if needed)</FormLabel>
<div className="mt-2 rounded-md overflow-hidden border flex-grow">
<GoogleMapWithDrawing
onShapeDrawn={handleShapeDrawn}
// Pass initial coordinates to center the map
initialCenter={{ lat: initialData.lat, lng: initialData.lon }}
initialZoom={15} // Or a suitable zoom level
// You could potentially pass the existing farm marker as an initial feature:
initialFeatures={[{ type: "marker", position: { lat: initialData.lat, lng: initialData.lon } }]}
/>
</div>
<FormDescription className="mt-2">
Click the marker tool and place a new marker to update coordinates.
</FormDescription>
</div>
</div>
);
}

View File

@ -1,19 +1,28 @@
"use client"; "use client";
import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardFooter } from "@/components/ui/card";
import { MapPin, Sprout, Plus, CalendarDays, ArrowRight } from "lucide-react"; import { MapPin, Sprout, Plus, ArrowRight, MoreVertical, Edit, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Farm } from "@/types"; import type { Farm } from "@/types";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export interface FarmCardProps { export interface FarmCardProps {
variant: "farm" | "add"; variant: "farm" | "add";
farm?: Farm; // Use updated Farm type farm?: Farm; // Use updated Farm type
onClick?: () => void; onClick?: () => void;
onEditClick?: (e: React.MouseEvent) => void; // Callback for edit
onDeleteClick?: (e: React.MouseEvent) => void; // Callback for delete
} }
export function FarmCard({ variant, farm, onClick }: FarmCardProps) { export function FarmCard({ variant, farm, onClick, onEditClick, onDeleteClick }: FarmCardProps) {
const cardClasses = cn( const cardClasses = cn(
"w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border", "w-full h-full overflow-hidden transition-all duration-200 hover:shadow-lg border",
variant === "add" variant === "add"
@ -21,6 +30,10 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
: "bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700 border-muted/60" : "bg-white dark:bg-slate-800 hover:bg-muted/10 dark:hover:bg-slate-700 border-muted/60"
); );
// Stop propagation for dropdown menu trigger and items
const stopPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
};
if (variant === "add") { if (variant === "add") {
return ( return (
<Card className={cardClasses} onClick={onClick}> <Card className={cardClasses} onClick={onClick}>
@ -43,49 +56,81 @@ export function FarmCard({ variant, farm, onClick }: FarmCardProps) {
}).format(new Date(farm.createdAt)); }).format(new Date(farm.createdAt));
return ( return (
<Card className={cardClasses} onClick={onClick}> <Card className={cardClasses}>
<CardHeader className="p-4 pb-0"> <CardHeader className="p-4 pb-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2">
<Badge <Badge
variant="outline" variant="outline"
className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200"> className="capitalize bg-green-50 dark:bg-green-900 text-green-700 dark:text-green-300 border-green-200 flex-shrink-0">
{farm.farmType} {farm.farmType}
</Badge> </Badge>
<div className="flex items-center text-xs text-muted-foreground"> {/* Actions Dropdown */}
<CalendarDays className="h-3 w-3 mr-1" /> <DropdownMenu>
{formattedDate} <DropdownMenuTrigger asChild onClick={stopPropagation}>
</div> <Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:bg-muted/50">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Farm Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={stopPropagation}>
<DropdownMenuItem onClick={onEditClick}>
<Edit className="mr-2 h-4 w-4" />
<span>Edit Farm</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
onClick={onDeleteClick}>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete Farm</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="p-4"> {/* Use div for clickable area if needed, or rely on button */}
<div className="flex items-start gap-3"> <div className="flex-grow cursor-pointer" onClick={onClick}>
<div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center"> <CardContent className="p-4">
<Sprout className="h-5 w-5 text-green-600" /> <div className="flex items-start gap-3">
</div> <div className="h-10 w-10 rounded-full flex-shrink-0 flex items-center justify-center bg-muted/40">
<div> <Sprout className="h-5 w-5 text-primary" />
<h3 className="text-xl font-medium mb-1 truncate">{farm.name}</h3>
<div className="flex items-center text-sm text-muted-foreground mb-2">
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
<span className="truncate">{farm.lat}</span>
</div> </div>
<div className="grid grid-cols-2 gap-2 mt-3"> {/* Ensure text truncates */}
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center"> <div className="min-w-0 flex-1">
<p className="text-xs text-muted-foreground">Area</p> <h3 className="text-lg font-medium mb-1 truncate" title={farm.name}>
<p className="font-medium">{farm.totalSize}</p> {farm.name}
</h3>
<div className="flex items-center text-sm text-muted-foreground mb-2 truncate">
<MapPin className="h-3.5 w-3.5 mr-1 flex-shrink-0" />
{/* Display truncated location or just Lat/Lon */}
<span className="truncate" title={`Lat: ${farm.lat}, Lon: ${farm.lon}`}>
Lat: {farm.lat.toFixed(3)}, Lon: {farm.lon.toFixed(3)}
</span>
</div> </div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center"> <div className="grid grid-cols-2 gap-2 mt-3">
<p className="text-xs text-muted-foreground">Crops</p> <div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="font-medium">{farm.crops ? farm.crops.length : 0}</p> <p className="text-xs text-muted-foreground">Area</p>
<p className="font-medium truncate" title={farm.totalSize || "N/A"}>
{farm.totalSize || "N/A"}
</p>
</div>
<div className="bg-muted/30 dark:bg-muted/20 rounded-md p-2 text-center">
<p className="text-xs text-muted-foreground">Crops</p>
<p className="font-medium">{farm.crops ? farm.crops.length : 0}</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </CardContent>
</CardContent> </div>
<CardFooter className="p-4 pt-0"> <CardFooter className="p-4 pt-0 mt-auto">
{" "}
{/* Keep footer outside clickable area */}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="ml-auto gap-1 text-green-600 hover:text-green-700 hover:bg-green-50/50 dark:hover:bg-green-800"> className="ml-auto gap-1 text-primary hover:text-primary/80 hover:bg-primary/10"
onClick={onClick}>
View details <ArrowRight className="h-3.5 w-3.5" /> View details <ArrowRight className="h-3.5 w-3.5" />
</Button> </Button>
</CardFooter> </CardFooter>

View File

@ -20,11 +20,25 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { FarmCard } from "./farm-card"; import { FarmCard } from "./farm-card";
import { AddFarmForm } from "./add-farm-form"; import { AddFarmForm } from "./add-farm-form";
import { EditFarmForm } from "./edit-farm-form";
import type { Farm } from "@/types"; import type { Farm } from "@/types";
import { fetchFarms, createFarm } from "@/api/farm"; import { fetchFarms, createFarm, updateFarm, deleteFarm } from "@/api/farm";
import { toast } from "sonner";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
export default function FarmSetupPage() { export default function FarmSetupPage() {
const router = useRouter(); const router = useRouter();
@ -33,27 +47,68 @@ export default function FarmSetupPage() {
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeFilter, setActiveFilter] = useState<string>("all"); const [activeFilter, setActiveFilter] = useState<string>("all");
const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest"); const [sortOrder, setSortOrder] = useState<"newest" | "oldest" | "alphabetical">("newest");
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); // State for edit dialog
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); // State for delete dialog
const [selectedFarm, setSelectedFarm] = useState<Farm | null>(null); // Farm to edit/delete
// --- Fetch Farms ---
const { const {
data: farms, // Type is Farm[] now data: farms,
isLoading, isLoading,
isError, isError,
error, error,
} = useQuery<Farm[]>({ } = useQuery<Farm[]>({
// Use Farm[] type
queryKey: ["farms"], queryKey: ["farms"],
queryFn: fetchFarms, queryFn: fetchFarms,
staleTime: 60 * 1000, staleTime: 60 * 1000,
}); });
const mutation = useMutation({ // --- Create Farm Mutation ---
// Pass the correct type to createFarm const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) => mutationFn: (data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>) =>
createFarm(data), createFarm(data),
onSuccess: () => { onSuccess: (newFarm) => {
queryClient.invalidateQueries({ queryKey: ["farms"] }); queryClient.invalidateQueries({ queryKey: ["farms"] });
setIsDialogOpen(false); setIsAddDialogOpen(false);
toast.success(`Farm "${newFarm.name}" created successfully!`);
},
onError: (error) => {
toast.error(`Failed to create farm: ${(error as Error).message}`);
},
});
// --- Update Farm Mutation ---
const updateMutation = useMutation({
mutationFn: (data: {
farmId: string;
payload: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>;
}) => updateFarm(data.farmId, data.payload),
onSuccess: (updatedFarm) => {
queryClient.invalidateQueries({ queryKey: ["farms"] });
setIsEditDialogOpen(false);
setSelectedFarm(null);
toast.success(`Farm "${updatedFarm.name}" updated successfully!`);
},
onError: (error) => {
toast.error(`Failed to update farm: ${(error as Error).message}`);
},
});
// --- Delete Farm Mutation ---
const deleteMutation = useMutation({
mutationFn: (farmId: string) => deleteFarm(farmId),
onSuccess: (_, farmId) => {
// Second arg is the variable passed to mutate
queryClient.invalidateQueries({ queryKey: ["farms"] });
// Optionally remove specific farm query if cached elsewhere: queryClient.removeQueries({ queryKey: ["farm", farmId] });
setIsDeleteDialogOpen(false);
setSelectedFarm(null);
toast.success(`Farm deleted successfully.`);
},
onError: (error) => {
toast.error(`Failed to delete farm: ${(error as Error).message}`);
setIsDeleteDialogOpen(false); // Close dialog even on error
}, },
}); });
@ -69,6 +124,35 @@ export default function FarmSetupPage() {
// UpdatedAt: string; // UpdatedAt: string;
// } // }
const handleAddFarmSubmit = async (data: Partial<Farm>) => {
await createMutation.mutateAsync(data);
};
const handleEditFarmSubmit = async (
data: Partial<Omit<Farm, "uuid" | "createdAt" | "updatedAt" | "crops" | "ownerId">>
) => {
if (!selectedFarm) return;
await updateMutation.mutateAsync({ farmId: selectedFarm.uuid, payload: data });
};
const openEditDialog = (farm: Farm, e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click
setSelectedFarm(farm);
setIsEditDialogOpen(true);
};
const openDeleteDialog = (farm: Farm, e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click
setSelectedFarm(farm);
setIsDeleteDialogOpen(true);
};
const confirmDelete = () => {
if (!selectedFarm) return;
deleteMutation.mutate(selectedFarm.uuid);
};
// --- Filtering and Sorting Logic ---
const filteredAndSortedFarms = (farms || []) const filteredAndSortedFarms = (farms || [])
.filter( .filter(
(farm) => (farm) =>
@ -90,10 +174,6 @@ export default function FarmSetupPage() {
// Get distinct farm types. // Get distinct farm types.
const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType const farmTypes = ["all", ...new Set((farms || []).map((farm) => farm.farmType))]; // Use camelCase farmType
const handleAddFarm = async (data: Partial<Farm>) => {
await mutation.mutateAsync(data);
};
return ( return (
<div className="min-h-screen bg-gradient-to-b"> <div className="min-h-screen bg-gradient-to-b">
<div className="container max-w-7xl p-6 mx-auto"> <div className="container max-w-7xl p-6 mx-auto">
@ -114,7 +194,7 @@ export default function FarmSetupPage() {
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
</div> </div>
<Button onClick={() => setIsDialogOpen(true)} className="gap-2 bg-green-600 hover:bg-green-700"> <Button onClick={() => setIsAddDialogOpen(true)} className="gap-2 bg-green-600 hover:bg-green-700">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Add Farm Add Farm
</Button> </Button>
@ -128,8 +208,9 @@ export default function FarmSetupPage() {
<Badge <Badge
key={type} key={type}
variant={activeFilter === type ? "default" : "outline"} variant={activeFilter === type ? "default" : "outline"}
className={`capitalize cursor-pointer ${ className={`capitalize cursor-pointer rounded-full px-3 py-1 text-sm ${
activeFilter === type ? "bg-green-600" : "hover:bg-green-100" // Made rounded-full
activeFilter === type ? "bg-primary text-primary-foreground" : "hover:bg-accent" // Adjusted colors
}`} }`}
onClick={() => setActiveFilter(type)}> onClick={() => setActiveFilter(type)}>
{type === "all" ? "All Farms" : type} {type === "all" ? "All Farms" : type}
@ -148,25 +229,25 @@ export default function FarmSetupPage() {
<DropdownMenuLabel>Sort by</DropdownMenuLabel> <DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className={sortOrder === "newest" ? "bg-green-50" : ""} className={sortOrder === "newest" ? "bg-accent" : ""} // Use accent for selection
onClick={() => setSortOrder("newest")}> onClick={() => setSortOrder("newest")}>
<Calendar className="h-4 w-4 mr-2" /> <Calendar className="h-4 w-4 mr-2" />
Newest first Newest first
{sortOrder === "newest" && <Check className="h-4 w-4 ml-2" />} {sortOrder === "newest" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className={sortOrder === "oldest" ? "bg-green-50" : ""} className={sortOrder === "oldest" ? "bg-accent" : ""}
onClick={() => setSortOrder("oldest")}> onClick={() => setSortOrder("oldest")}>
<Calendar className="h-4 w-4 mr-2" /> <Calendar className="h-4 w-4 mr-2" />
Oldest first Oldest first
{sortOrder === "oldest" && <Check className="h-4 w-4 ml-2" />} {sortOrder === "oldest" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
className={sortOrder === "alphabetical" ? "bg-green-50" : ""} className={sortOrder === "alphabetical" ? "bg-accent" : ""}
onClick={() => setSortOrder("alphabetical")}> onClick={() => setSortOrder("alphabetical")}>
<Filter className="h-4 w-4 mr-2" /> <Filter className="h-4 w-4 mr-2" />
Alphabetical Alphabetical
{sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-2" />} {sortOrder === "alphabetical" && <Check className="h-4 w-4 ml-auto" />}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -178,21 +259,40 @@ export default function FarmSetupPage() {
{isError && ( {isError && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle> <AlertTitle>Error Loading Farms</AlertTitle>
<AlertDescription>{(error as Error)?.message}</AlertDescription> <AlertDescription>{(error as Error)?.message}</AlertDescription>
</Alert> </Alert>
)} )}
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="flex flex-col items-center justify-center py-12"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<Loader2 className="h-8 w-8 text-green-600 animate-spin mb-4" /> {[...Array(4)].map(
<p className="text-muted-foreground">Loading your farms...</p> (
_,
i // Render skeleton cards
) => (
<Card key={i} className="w-full h-[250px]">
<CardHeader className="p-4 pb-0">
<Skeleton className="h-4 w-1/3" />
</CardHeader>
<CardContent className="p-4 space-y-3">
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
</CardContent>
<CardFooter className="p-4 pt-0">
<Skeleton className="h-8 w-24 ml-auto" />
</CardFooter>
</Card>
)
)}
</div> </div>
)} )}
{/* Empty state */} {/* Empty state */}
{!isLoading && !isError && filteredAndSortedFarms.length === 0 && ( {!isLoading && !isError && filteredAndSortedFarms.length === 0 && (
// ... (Empty state remains the same) ...
<div className="flex flex-col items-center justify-center py-12 bg-muted/20 rounded-lg border border-dashed"> <div className="flex flex-col items-center justify-center py-12 bg-muted/20 rounded-lg border border-dashed">
<div className="bg-green-100 p-3 rounded-full mb-4"> <div className="bg-green-100 p-3 rounded-full mb-4">
<Leaf className="h-6 w-6 text-green-600" /> <Leaf className="h-6 w-6 text-green-600" />
@ -204,7 +304,7 @@ export default function FarmSetupPage() {
</p> </p>
) : ( ) : (
<p className="text-muted-foreground text-center max-w-md mb-6"> <p className="text-muted-foreground text-center max-w-md mb-6">
You haven&apos;t added any farms yet. Get started by adding your first farm. You haven't added any farms yet. Get started by adding your first farm.
</p> </p>
)} )}
<Button <Button
@ -212,7 +312,7 @@ export default function FarmSetupPage() {
setSearchQuery(""); setSearchQuery("");
setActiveFilter("all"); setActiveFilter("all");
if (!farms || farms.length === 0) { if (!farms || farms.length === 0) {
setIsDialogOpen(true); setIsAddDialogOpen(true);
} }
}} }}
className="gap-2"> className="gap-2">
@ -232,17 +332,31 @@ export default function FarmSetupPage() {
{!isLoading && !isError && filteredAndSortedFarms.length > 0 && ( {!isLoading && !isError && filteredAndSortedFarms.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<AnimatePresence> <AnimatePresence>
<motion.div /* ... */> {/* Add Farm Card */}
<FarmCard variant="add" onClick={() => setIsDialogOpen(true)} /> <motion.div
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}>
<FarmCard variant="add" onClick={() => setIsAddDialogOpen(true)} />
</motion.div> </motion.div>
{/* Existing Farm Cards */}
{filteredAndSortedFarms.map((farm, index) => ( {filteredAndSortedFarms.map((farm, index) => (
<motion.div <motion.div
key={farm.uuid} // Use camelCase uuid initial={{ opacity: 0, y: 20 }} layout // Add layout animation
key={farm.uuid}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }} exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, delay: index * 0.05 }} transition={{ duration: 0.2, delay: index * 0.05 }}
className="col-span-1"> className="col-span-1">
<FarmCard variant="farm" farm={farm} onClick={() => router.push(`/farms/${farm.uuid}`)} /> <FarmCard
variant="farm"
farm={farm}
onClick={() => router.push(`/farms/${farm.uuid}`)}
onEditClick={(e) => openEditDialog(farm, e)} // Pass handler
onDeleteClick={(e) => openDeleteDialog(farm, e)} // Pass handler
/>
</motion.div> </motion.div>
))} ))}
</AnimatePresence> </AnimatePresence>
@ -252,16 +366,57 @@ export default function FarmSetupPage() {
</div> </div>
{/* Add Farm Dialog */} {/* Add Farm Dialog */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[800px] md:max-w-[900px] lg:max-w-[1000px] xl:max-w-5xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Add New Farm</DialogTitle> <DialogTitle>Add New Farm</DialogTitle>
<DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription> <DialogDescription>Fill out the details below to add a new farm to your account.</DialogDescription>
</DialogHeader> </DialogHeader>
{/* Pass handleAddFarm (which now expects Partial<Farm>) */} <AddFarmForm onSubmit={handleAddFarmSubmit} onCancel={() => setIsAddDialogOpen(false)} />
<AddFarmForm onSubmit={handleAddFarm} onCancel={() => setIsDialogOpen(false)} />
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Edit Farm Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[800px] md:max-w-[900px] lg:max-w-[1000px] xl:max-w-5xl">
<DialogHeader>
<DialogTitle>Edit Farm: {selectedFarm?.name}</DialogTitle>
<DialogDescription>Update the details for this farm.</DialogDescription>
</DialogHeader>
{/* Create or use an EditFarmForm component */}
{selectedFarm && (
<EditFarmForm
initialData={selectedFarm}
onSubmit={handleEditFarmSubmit}
onCancel={() => setIsEditDialogOpen(false)}
isSubmitting={updateMutation.isPending} // Pass submitting state
/>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the farm "{selectedFarm?.name}" and all
associated crops and data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={deleteMutation.isPending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
{deleteMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Delete Farm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View File

@ -4,9 +4,6 @@ import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import DynamicBreadcrumb from "./dynamic-breadcrumb";
import { extractRoute } from "@/lib/utils";
import { usePathname } from "next/navigation";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { useForm, FormProvider } from "react-hook-form"; import { useForm, FormProvider } from "react-hook-form";
import { APIProvider } from "@vis.gl/react-google-maps"; import { APIProvider } from "@vis.gl/react-google-maps";
@ -16,8 +13,7 @@ export default function AppLayout({
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const pathname = usePathname(); // const pathname = usePathname();
const currentPathname = extractRoute(pathname);
const form = useForm(); const form = useForm();
return ( return (
@ -31,7 +27,7 @@ export default function AppLayout({
<SidebarTrigger className="-ml-1" /> <SidebarTrigger className="-ml-1" />
<ThemeToggle /> <ThemeToggle />
<Separator orientation="vertical" className="mr-2 h-4" /> <Separator orientation="vertical" className="mr-2 h-4" />
<DynamicBreadcrumb pathname={currentPathname} /> {/* <DynamicBreadcrumb pathname={currentPathname} /> */}
</div> </div>
</header> </header>
{children} {children}

View File

@ -1,52 +1,160 @@
// google-map-with-drawing.tsx import React, { useEffect, useRef, useState } from "react";
import React from "react"; import { Map, useMap, useMapsLibrary, MapControl, ControlPosition } from "@vis.gl/react-google-maps";
import { ControlPosition, Map, MapControl } from "@vis.gl/react-google-maps";
import { UndoRedoControl } from "@/components/map-component/undo-redo-control"; import { UndoRedoControl } from "@/components/map-component/undo-redo-control";
// Import ShapeData and useDrawingManager from the correct path import { useDrawingManager } from "@/components/map-component/use-drawing-manager";
import { useDrawingManager, type ShapeData } from "@/components/map-component/use-drawing-manager"; // Adjust path if needed import { GeoFeatureData, GeoPosition } from "@/types";
// Export the type so the form can use it export type ShapeData = GeoFeatureData;
export { type ShapeData };
// Define props for the component
interface GoogleMapWithDrawingProps { interface GoogleMapWithDrawingProps {
onShapeDrawn: (data: ShapeData) => void; // Callback prop onShapeDrawn?: (data: GeoFeatureData) => void;
// Add any other props you might need, e.g., initialCenter, initialZoom initialCenter?: GeoPosition;
initialCenter?: { lat: number; lng: number };
initialZoom?: number; initialZoom?: number;
initialFeatures?: GeoFeatureData[] | null;
drawingMode?: google.maps.drawing.OverlayType | null;
editable?: boolean;
displayOnly?: boolean;
mapId?: string;
} }
// Rename DrawingExample to GoogleMapWithDrawing and accept props const GoogleMapWithDrawingInternal = ({
const GoogleMapWithDrawing = ({ onShapeDrawn,
onShapeDrawn, // Destructure the callback prop initialCenter = { lat: 13.7563, lng: 100.5018 },
initialCenter = { lat: 13.7563, lng: 100.5018 }, // Default center initialZoom = 10,
initialZoom = 10, // Default zoom initialFeatures,
drawingMode = null,
editable = true,
displayOnly = false,
}: GoogleMapWithDrawingProps) => { }: GoogleMapWithDrawingProps) => {
// Pass the onShapeDrawn callback directly to the hook const map = useMap();
const geometryLib = useMapsLibrary("geometry");
const [drawnOverlays, setDrawnOverlays] = useState<
(google.maps.Marker | google.maps.Polygon | google.maps.Polyline)[]
>([]);
const isMountedRef = useRef(false);
const drawingManager = useDrawingManager(onShapeDrawn); const drawingManager = useDrawingManager(onShapeDrawn);
useEffect(() => {
if (!map || !initialFeatures || initialFeatures.length === 0 || !geometryLib) return;
if (isMountedRef.current && !displayOnly) return;
drawnOverlays.forEach((overlay) => overlay.setMap(null));
const newOverlays: (google.maps.Marker | google.maps.Polygon | google.maps.Polyline)[] = [];
const bounds = new google.maps.LatLngBounds();
initialFeatures.forEach((feature) => {
if (!feature) return;
let overlay: google.maps.Marker | google.maps.Polygon | google.maps.Polyline | null = null;
try {
if (feature.type === "marker" && feature.position) {
const marker = new google.maps.Marker({
position: feature.position,
map: map,
});
bounds.extend(feature.position);
overlay = marker;
} else if (feature.type === "polygon" && feature.path && feature.path.length > 0) {
const polygon = new google.maps.Polygon({
paths: feature.path,
map: map,
strokeColor: "#FF0000",
strokeOpacity: 0.8,
strokeWeight: 2,
fillColor: "#FF0000",
fillOpacity: 0.35,
});
feature.path.forEach((pos) => bounds.extend(pos));
overlay = polygon;
} else if (feature.type === "polyline" && feature.path && feature.path.length > 0) {
const polyline = new google.maps.Polyline({
path: feature.path,
map: map,
strokeColor: "#0000FF",
strokeOpacity: 1.0,
strokeWeight: 3,
});
feature.path.forEach((pos) => bounds.extend(pos));
overlay = polyline;
}
if (overlay) {
newOverlays.push(overlay);
}
} catch (e) {
console.error("Error creating map overlay:", e, "Feature:", feature);
}
});
setDrawnOverlays(newOverlays);
if (newOverlays.length === 1 && initialFeatures[0]?.type === "marker") {
map.setCenter(initialFeatures[0].position);
map.setZoom(initialZoom + 4);
} else if (!bounds.isEmpty()) {
map.fitBounds(bounds);
} else {
map.setCenter(initialCenter);
map.setZoom(initialZoom);
}
isMountedRef.current = true;
return () => {
newOverlays.forEach((overlay) => {
try {
overlay.setMap(null);
} catch (e) {
console.warn("Error removing overlay during cleanup:", e);
}
});
setDrawnOverlays([]);
isMountedRef.current = false;
};
}, [map, initialFeatures, geometryLib, displayOnly]);
useEffect(() => {
if (drawingManager) {
drawingManager.setOptions({
drawingControl: !displayOnly,
drawingMode: displayOnly ? null : drawingMode,
markerOptions: {
draggable: !displayOnly && editable,
},
polygonOptions: {
editable: !displayOnly && editable,
draggable: !displayOnly && editable,
},
polylineOptions: {
editable: !displayOnly && editable,
draggable: !displayOnly && editable,
},
});
}
}, [drawingManager, displayOnly, drawingMode, editable]);
return ( return (
<> <>
{/* Use props for map defaults */}
<Map <Map
defaultZoom={initialZoom} defaultZoom={initialZoom}
defaultCenter={initialCenter} defaultCenter={initialCenter}
gestureHandling={"greedy"} gestureHandling={"greedy"}
disableDefaultUI={true} disableDefaultUI={true}
mapId={"YOUR_MAP_ID"} // Recommended: Add a Map ID mapId={"YOUR_MAP_ID"}
/> />
{/* Render controls only if drawingManager is available */} {!displayOnly && drawingManager && (
{drawingManager && (
<MapControl position={ControlPosition.TOP_LEFT}> <MapControl position={ControlPosition.TOP_LEFT}>
{/* Pass drawingManager to UndoRedoControl */} {editable && <UndoRedoControl drawingManager={drawingManager} />}
<UndoRedoControl drawingManager={drawingManager} />
</MapControl> </MapControl>
)} )}
{/* The drawing controls (marker, polygon etc.) are added by useDrawingManager */}
</> </>
); );
}; };
const GoogleMapWithDrawing = (props: GoogleMapWithDrawingProps) => {
return <GoogleMapWithDrawingInternal {...props} />;
};
export default GoogleMapWithDrawing; export default GoogleMapWithDrawing;

View File

@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -10,6 +10,7 @@
}, },
"dependencies": { "dependencies": {
"@hookform/resolvers": "^4.0.0", "@hookform/resolvers": "^4.0.0",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4", "@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3", "@radix-ui/react-collapsible": "^1.1.3",

File diff suppressed because it is too large Load Diff