mirror of
https://github.com/ForFarmTeam/ForFarm.git
synced 2025-12-19 14:04:08 +01:00
Merge branch 'main' into feature-test
This commit is contained in:
commit
c0a8a8a6be
@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"github.com/forfarm/backend/internal/cache"
|
||||||
"github.com/forfarm/backend/internal/config"
|
"github.com/forfarm/backend/internal/config"
|
||||||
"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"
|
||||||
@ -29,6 +30,7 @@ type api struct {
|
|||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
eventPublisher domain.EventPublisher
|
eventPublisher domain.EventPublisher
|
||||||
|
cache cache.Cache
|
||||||
|
|
||||||
userRepo domain.UserRepository
|
userRepo domain.UserRepository
|
||||||
cropRepo domain.CroplandRepository
|
cropRepo domain.CroplandRepository
|
||||||
@ -54,17 +56,21 @@ func NewAPI(
|
|||||||
pool *pgxpool.Pool,
|
pool *pgxpool.Pool,
|
||||||
eventPublisher domain.EventPublisher,
|
eventPublisher domain.EventPublisher,
|
||||||
analyticsRepo domain.AnalyticsRepository,
|
analyticsRepo domain.AnalyticsRepository,
|
||||||
inventoryRepo domain.InventoryRepository,
|
|
||||||
croplandRepo domain.CroplandRepository,
|
|
||||||
farmRepo domain.FarmRepository,
|
farmRepo domain.FarmRepository,
|
||||||
) *api {
|
) *api {
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
|
logger.Info("creating memory cache")
|
||||||
|
memoryCache := cache.NewMemoryCache(1*time.Hour, 2*time.Hour)
|
||||||
|
|
||||||
userRepository := repository.NewPostgresUser(pool)
|
userRepository := repository.NewPostgresUser(pool)
|
||||||
plantRepository := repository.NewPostgresPlant(pool)
|
plantRepository := repository.NewPostgresPlant(pool, memoryCache)
|
||||||
|
inventoryRepo := repository.NewPostgresInventory(pool, eventPublisher, memoryCache)
|
||||||
|
harvestRepository := repository.NewPostgresHarvest(pool, memoryCache)
|
||||||
knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool)
|
knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool)
|
||||||
harvestRepository := repository.NewPostgresHarvest(pool)
|
croplandRepo := repository.NewPostgresCropland(pool)
|
||||||
|
croplandRepo.SetEventPublisher(eventPublisher)
|
||||||
|
|
||||||
owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger)
|
owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger)
|
||||||
cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL)
|
cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL)
|
||||||
@ -88,6 +94,7 @@ func NewAPI(
|
|||||||
logger: logger,
|
logger: logger,
|
||||||
httpClient: client,
|
httpClient: client,
|
||||||
eventPublisher: eventPublisher,
|
eventPublisher: eventPublisher,
|
||||||
|
cache: memoryCache,
|
||||||
|
|
||||||
userRepo: userRepository,
|
userRepo: userRepository,
|
||||||
cropRepo: croplandRepo,
|
cropRepo: croplandRepo,
|
||||||
|
|||||||
14
backend/internal/cache/cache.go
vendored
Normal file
14
backend/internal/cache/cache.go
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Cache interface {
|
||||||
|
Get(key string) (interface{}, bool)
|
||||||
|
Set(key string, value interface{}, ttl time.Duration)
|
||||||
|
Delete(key string)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultExpiration time.Duration = 0
|
||||||
|
NoExpiration time.Duration = -1
|
||||||
|
)
|
||||||
37
backend/internal/cache/memory_cache.go
vendored
Normal file
37
backend/internal/cache/memory_cache.go
vendored
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package cache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gocache "github.com/patrickmn/go-cache"
|
||||||
|
)
|
||||||
|
|
||||||
|
type memoryCache struct {
|
||||||
|
client *gocache.Cache
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMemoryCache(defaultExpiration, cleanupInterval time.Duration) Cache {
|
||||||
|
return &memoryCache{
|
||||||
|
client: gocache.New(defaultExpiration, cleanupInterval),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryCache) Get(key string) (interface{}, bool) {
|
||||||
|
return m.client.Get(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryCache) Set(key string, value interface{}, ttl time.Duration) {
|
||||||
|
var expiration time.Duration
|
||||||
|
if ttl == DefaultExpiration {
|
||||||
|
expiration = gocache.DefaultExpiration
|
||||||
|
} else if ttl == NoExpiration {
|
||||||
|
expiration = gocache.NoExpiration
|
||||||
|
} else {
|
||||||
|
expiration = ttl
|
||||||
|
}
|
||||||
|
m.client.Set(key, value, expiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryCache) Delete(key string) {
|
||||||
|
m.client.Delete(key)
|
||||||
|
}
|
||||||
@ -55,11 +55,6 @@ func APICmd(ctx context.Context) *cobra.Command {
|
|||||||
farmRepo := repository.NewPostgresFarm(pool)
|
farmRepo := repository.NewPostgresFarm(pool)
|
||||||
farmRepo.SetEventPublisher(eventBus)
|
farmRepo.SetEventPublisher(eventBus)
|
||||||
|
|
||||||
inventoryRepo := repository.NewPostgresInventory(pool, eventBus)
|
|
||||||
|
|
||||||
croplandRepo := repository.NewPostgresCropland(pool)
|
|
||||||
croplandRepo.SetEventPublisher(eventBus)
|
|
||||||
|
|
||||||
projection := event.NewFarmAnalyticsProjection(eventBus, analyticsRepo, logger)
|
projection := event.NewFarmAnalyticsProjection(eventBus, analyticsRepo, logger)
|
||||||
go func() {
|
go func() {
|
||||||
if err := projection.Start(ctx); err != nil {
|
if err := projection.Start(ctx); err != nil {
|
||||||
@ -68,7 +63,7 @@ func APICmd(ctx context.Context) *cobra.Command {
|
|||||||
}()
|
}()
|
||||||
logger.Info("Farm Analytics Projection started")
|
logger.Info("Farm Analytics Projection started")
|
||||||
|
|
||||||
apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, inventoryRepo, croplandRepo, farmRepo)
|
apiInstance := api.NewAPI(ctx, logger, pool, eventBus, analyticsRepo, farmRepo)
|
||||||
|
|
||||||
weatherFetcher := apiInstance.GetWeatherFetcher()
|
weatherFetcher := apiInstance.GetWeatherFetcher()
|
||||||
weatherInterval, err := time.ParseDuration(config.WEATHER_FETCH_INTERVAL)
|
weatherInterval, err := time.ParseDuration(config.WEATHER_FETCH_INTERVAL)
|
||||||
|
|||||||
@ -2,19 +2,34 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/forfarm/backend/internal/cache"
|
||||||
"github.com/forfarm/backend/internal/domain"
|
"github.com/forfarm/backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheKeyHarvestUnits = "harvest:units"
|
||||||
|
)
|
||||||
|
|
||||||
type postgresHarvestRepository struct {
|
type postgresHarvestRepository struct {
|
||||||
conn Connection
|
conn Connection
|
||||||
|
cache cache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPostgresHarvest(conn Connection) domain.HarvestRepository {
|
func NewPostgresHarvest(conn Connection, c cache.Cache) domain.HarvestRepository {
|
||||||
return &postgresHarvestRepository{conn: conn}
|
return &postgresHarvestRepository{conn: conn, cache: c}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresHarvestRepository) GetUnits(ctx context.Context) ([]domain.HarvestUnit, error) {
|
func (p *postgresHarvestRepository) GetUnits(ctx context.Context) ([]domain.HarvestUnit, error) {
|
||||||
|
if cached, found := p.cache.Get(cacheKeyHarvestUnits); found {
|
||||||
|
if units, ok := cached.([]domain.HarvestUnit); ok {
|
||||||
|
slog.DebugContext(ctx, "Cache hit for GetHarvestUnits", "key", cacheKeyHarvestUnits)
|
||||||
|
return units, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.DebugContext(ctx, "Cache miss for GetHarvestUnits", "key", cacheKeyHarvestUnits)
|
||||||
|
|
||||||
query := `SELECT id, name FROM harvest_units ORDER BY id`
|
query := `SELECT id, name FROM harvest_units ORDER BY id`
|
||||||
rows, err := p.conn.Query(ctx, query)
|
rows, err := p.conn.Query(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -30,5 +45,13 @@ func (p *postgresHarvestRepository) GetUnits(ctx context.Context) ([]domain.Harv
|
|||||||
}
|
}
|
||||||
units = append(units, u)
|
units = append(units, u)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(units) > 0 {
|
||||||
|
p.cache.Set(cacheKeyHarvestUnits, units, cacheTTLStatic)
|
||||||
|
}
|
||||||
|
|
||||||
return units, nil
|
return units, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,20 +3,28 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/forfarm/backend/internal/cache"
|
||||||
"github.com/forfarm/backend/internal/domain"
|
"github.com/forfarm/backend/internal/domain"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheKeyInventoryStatuses = "inventory:statuses"
|
||||||
|
cacheKeyInventoryCategories = "inventory:categories"
|
||||||
|
)
|
||||||
|
|
||||||
type postgresInventoryRepository struct {
|
type postgresInventoryRepository struct {
|
||||||
conn Connection
|
conn Connection
|
||||||
eventPublisher domain.EventPublisher
|
eventPublisher domain.EventPublisher
|
||||||
|
cache cache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPostgresInventory(conn Connection, publisher domain.EventPublisher) domain.InventoryRepository {
|
func NewPostgresInventory(conn Connection, publisher domain.EventPublisher, c cache.Cache) domain.InventoryRepository {
|
||||||
return &postgresInventoryRepository{conn: conn, eventPublisher: publisher}
|
return &postgresInventoryRepository{conn: conn, eventPublisher: publisher, cache: c}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.InventoryItem, error) {
|
func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.InventoryItem, error) {
|
||||||
@ -342,6 +350,14 @@ func (p *postgresInventoryRepository) Delete(ctx context.Context, id, userID str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresInventoryRepository) GetStatuses(ctx context.Context) ([]domain.InventoryStatus, error) {
|
func (p *postgresInventoryRepository) GetStatuses(ctx context.Context) ([]domain.InventoryStatus, error) {
|
||||||
|
if cached, found := p.cache.Get(cacheKeyInventoryStatuses); found {
|
||||||
|
if statuses, ok := cached.([]domain.InventoryStatus); ok {
|
||||||
|
slog.DebugContext(ctx, "Cache hit for GetInventoryStatuses", "key", cacheKeyInventoryStatuses)
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.DebugContext(ctx, "Cache miss for GetInventoryStatuses", "key", cacheKeyInventoryStatuses)
|
||||||
|
|
||||||
query := `SELECT id, name FROM inventory_status ORDER BY id`
|
query := `SELECT id, name FROM inventory_status ORDER BY id`
|
||||||
rows, err := p.conn.Query(ctx, query)
|
rows, err := p.conn.Query(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -357,10 +373,26 @@ func (p *postgresInventoryRepository) GetStatuses(ctx context.Context) ([]domain
|
|||||||
}
|
}
|
||||||
statuses = append(statuses, s)
|
statuses = append(statuses, s)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(statuses) > 0 {
|
||||||
|
p.cache.Set(cacheKeyInventoryStatuses, statuses, cacheTTLStatic)
|
||||||
|
}
|
||||||
|
|
||||||
return statuses, nil
|
return statuses, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresInventoryRepository) GetCategories(ctx context.Context) ([]domain.InventoryCategory, error) {
|
func (p *postgresInventoryRepository) GetCategories(ctx context.Context) ([]domain.InventoryCategory, error) {
|
||||||
|
if cached, found := p.cache.Get(cacheKeyInventoryCategories); found {
|
||||||
|
if categories, ok := cached.([]domain.InventoryCategory); ok {
|
||||||
|
slog.DebugContext(ctx, "Cache hit for GetInventoryCategories", "key", cacheKeyInventoryCategories)
|
||||||
|
return categories, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.DebugContext(ctx, "Cache miss for GetInventoryCategories", "key", cacheKeyInventoryCategories)
|
||||||
|
|
||||||
query := `SELECT id, name FROM inventory_category ORDER BY id`
|
query := `SELECT id, name FROM inventory_category ORDER BY id`
|
||||||
rows, err := p.conn.Query(ctx, query)
|
rows, err := p.conn.Query(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -376,5 +408,13 @@ func (p *postgresInventoryRepository) GetCategories(ctx context.Context) ([]doma
|
|||||||
}
|
}
|
||||||
categories = append(categories, c)
|
categories = append(categories, c)
|
||||||
}
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(categories) > 0 {
|
||||||
|
p.cache.Set(cacheKeyInventoryCategories, categories, cacheTTLStatic)
|
||||||
|
}
|
||||||
|
|
||||||
return categories, nil
|
return categories, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,18 +2,28 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/forfarm/backend/internal/cache"
|
||||||
"github.com/forfarm/backend/internal/domain"
|
"github.com/forfarm/backend/internal/domain"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
cacheKeyPlantsAll = "plants:all"
|
||||||
|
cacheKeyPlantPrefix = "plant:uuid:"
|
||||||
|
cacheTTLStatic = 1 * time.Hour // Cache static lists for 1 hour
|
||||||
|
)
|
||||||
|
|
||||||
type postgresPlantRepository struct {
|
type postgresPlantRepository struct {
|
||||||
conn Connection
|
conn Connection
|
||||||
|
cache cache.Cache
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPostgresPlant(conn Connection) domain.PlantRepository {
|
func NewPostgresPlant(conn Connection, c cache.Cache) domain.PlantRepository {
|
||||||
return &postgresPlantRepository{conn: conn}
|
return &postgresPlantRepository{conn: conn, cache: c}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresPlantRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Plant, error) {
|
func (p *postgresPlantRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.Plant, error) {
|
||||||
@ -43,12 +53,27 @@ func (p *postgresPlantRepository) fetch(ctx context.Context, query string, args
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresPlantRepository) GetByUUID(ctx context.Context, uuid string) (domain.Plant, error) {
|
func (p *postgresPlantRepository) GetByUUID(ctx context.Context, uuid string) (domain.Plant, error) {
|
||||||
|
// Check cache first
|
||||||
|
cacheKey := cacheKeyPlantPrefix + uuid
|
||||||
|
if cached, found := p.cache.Get(cacheKey); found {
|
||||||
|
if plant, ok := cached.(domain.Plant); ok {
|
||||||
|
slog.DebugContext(ctx, "Cache hit for GetPlantByUUID", "key", cacheKey)
|
||||||
|
return plant, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.DebugContext(ctx, "Cache miss for GetPlantByUUID", "key", cacheKey)
|
||||||
|
|
||||||
query := `SELECT * FROM plants WHERE uuid = $1`
|
query := `SELECT * FROM plants WHERE uuid = $1`
|
||||||
plants, err := p.fetch(ctx, query, uuid)
|
plants, err := p.fetch(ctx, query, uuid)
|
||||||
if err != nil || len(plants) == 0 {
|
if err != nil {
|
||||||
|
return domain.Plant{}, err
|
||||||
|
}
|
||||||
|
if len(plants) == 0 {
|
||||||
return domain.Plant{}, domain.ErrNotFound
|
return domain.Plant{}, domain.ErrNotFound
|
||||||
}
|
}
|
||||||
return plants[0], nil
|
plant := plants[0]
|
||||||
|
p.cache.Set(cacheKey, plant, cacheTTLStatic)
|
||||||
|
return plant, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresPlantRepository) GetByName(ctx context.Context, name string) (domain.Plant, error) {
|
func (p *postgresPlantRepository) GetByName(ctx context.Context, name string) (domain.Plant, error) {
|
||||||
@ -61,8 +86,25 @@ func (p *postgresPlantRepository) GetByName(ctx context.Context, name string) (d
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresPlantRepository) GetAll(ctx context.Context) ([]domain.Plant, error) {
|
func (p *postgresPlantRepository) GetAll(ctx context.Context) ([]domain.Plant, error) {
|
||||||
|
if cached, found := p.cache.Get(cacheKeyPlantsAll); found {
|
||||||
|
if plants, ok := cached.([]domain.Plant); ok {
|
||||||
|
slog.DebugContext(ctx, "Cache hit for GetAllPlants", "key", cacheKeyPlantsAll)
|
||||||
|
return plants, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.DebugContext(ctx, "Cache miss for GetAllPlants", "key", cacheKeyPlantsAll)
|
||||||
|
|
||||||
query := `SELECT * FROM plants`
|
query := `SELECT * FROM plants`
|
||||||
return p.fetch(ctx, query)
|
plants, err := p.fetch(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(plants) > 0 {
|
||||||
|
p.cache.Set(cacheKeyPlantsAll, plants, cacheTTLStatic)
|
||||||
|
}
|
||||||
|
|
||||||
|
return plants, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresPlantRepository) Create(ctx context.Context, plant *domain.Plant) error {
|
func (p *postgresPlantRepository) Create(ctx context.Context, plant *domain.Plant) error {
|
||||||
@ -74,7 +116,13 @@ func (p *postgresPlantRepository) Create(ctx context.Context, plant *domain.Plan
|
|||||||
}
|
}
|
||||||
query := `INSERT INTO plants (uuid, name, light_profile_id, soil_condition_id, harvest_unit_id, created_at, updated_at)
|
query := `INSERT INTO plants (uuid, name, light_profile_id, soil_condition_id, harvest_unit_id, created_at, updated_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) RETURNING created_at, updated_at`
|
VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) RETURNING created_at, updated_at`
|
||||||
return p.conn.QueryRow(ctx, query, plant.UUID, plant.Name, plant.LightProfileID, plant.SoilConditionID, plant.HarvestUnitID).Scan(&plant.CreatedAt, &plant.UpdatedAt)
|
err := p.conn.QueryRow(ctx, query, plant.UUID, plant.Name, plant.LightProfileID, plant.SoilConditionID, plant.HarvestUnitID).Scan(&plant.CreatedAt, &plant.UpdatedAt)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
p.cache.Delete(cacheKeyPlantsAll)
|
||||||
|
slog.DebugContext(ctx, "Cache invalidated", "key", cacheKeyPlantsAll)
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresPlantRepository) Update(ctx context.Context, plant *domain.Plant) error {
|
func (p *postgresPlantRepository) Update(ctx context.Context, plant *domain.Plant) error {
|
||||||
@ -84,11 +132,21 @@ func (p *postgresPlantRepository) Update(ctx context.Context, plant *domain.Plan
|
|||||||
query := `UPDATE plants SET name = $2, light_profile_id = $3, soil_condition_id = $4,
|
query := `UPDATE plants SET name = $2, light_profile_id = $3, soil_condition_id = $4,
|
||||||
harvest_unit_id = $5, updated_at = NOW() WHERE uuid = $1`
|
harvest_unit_id = $5, updated_at = NOW() WHERE uuid = $1`
|
||||||
_, err := p.conn.Exec(ctx, query, plant.UUID, plant.Name, plant.LightProfileID, plant.SoilConditionID, plant.HarvestUnitID)
|
_, err := p.conn.Exec(ctx, query, plant.UUID, plant.Name, plant.LightProfileID, plant.SoilConditionID, plant.HarvestUnitID)
|
||||||
|
if err == nil {
|
||||||
|
p.cache.Delete(cacheKeyPlantsAll)
|
||||||
|
p.cache.Delete(cacheKeyPlantPrefix + plant.UUID)
|
||||||
|
slog.DebugContext(ctx, "Cache invalidated", "keys", []string{cacheKeyPlantsAll, cacheKeyPlantPrefix + plant.UUID})
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *postgresPlantRepository) Delete(ctx context.Context, uuid string) error {
|
func (p *postgresPlantRepository) Delete(ctx context.Context, uuid string) error {
|
||||||
query := `DELETE FROM plants WHERE uuid = $1`
|
query := `DELETE FROM plants WHERE uuid = $1`
|
||||||
_, err := p.conn.Exec(ctx, query, uuid)
|
_, err := p.conn.Exec(ctx, query, uuid)
|
||||||
|
if err == nil {
|
||||||
|
p.cache.Delete(cacheKeyPlantsAll)
|
||||||
|
p.cache.Delete(cacheKeyPlantPrefix + uuid)
|
||||||
|
slog.DebugContext(ctx, "Cache invalidated", "keys", []string{cacheKeyPlantsAll, cacheKeyPlantPrefix + uuid})
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,148 +1,103 @@
|
|||||||
import axiosInstance from "./config";
|
import axiosInstance from "./config";
|
||||||
|
import type {
|
||||||
|
KnowledgeArticle as BackendArticle,
|
||||||
|
TableOfContent as BackendTOC,
|
||||||
|
RelatedArticle as BackendRelated,
|
||||||
|
} from "@/types";
|
||||||
import type { Blog } from "@/types";
|
import type { Blog } from "@/types";
|
||||||
|
|
||||||
// Dummy blog data used as a fallback.
|
function mapBackendArticleToFrontendBlog(
|
||||||
const dummyBlogs: Blog[] = [
|
backendArticle: BackendArticle,
|
||||||
{
|
toc?: BackendTOC[],
|
||||||
id: 1,
|
related?: BackendRelated[]
|
||||||
title: "Sustainable Farming Practices for Modern Agriculture",
|
): Blog {
|
||||||
description:
|
return {
|
||||||
"Learn about eco-friendly farming techniques that can increase yield while preserving the environment.",
|
id: backendArticle.UUID,
|
||||||
date: "2023-05-15",
|
title: backendArticle.Title,
|
||||||
author: "Emma Johnson",
|
description: backendArticle.Content.substring(0, 150) + "...",
|
||||||
topic: "Sustainability",
|
date: backendArticle.PublishDate.toString(),
|
||||||
image: "/placeholder.svg?height=400&width=600",
|
author: backendArticle.Author,
|
||||||
readTime: "5 min read",
|
topic: backendArticle.Categories.length > 0 ? backendArticle.Categories[0] : "General",
|
||||||
featured: true,
|
image: backendArticle.ImageURL || "/placeholder.svg",
|
||||||
content: `<p>Sustainable farming is not just a trend; it's a necessary evolution in agricultural practices. […]</p>`,
|
readTime: backendArticle.ReadTime || "5 min read",
|
||||||
tableOfContents: [
|
featured: backendArticle.Categories.includes("Featured"),
|
||||||
{ id: "importance", title: "The Importance of Sustainable Agriculture", level: 1 },
|
content: backendArticle.Content,
|
||||||
{ id: "crop-rotation", title: "Crop Rotation and Diversification", level: 1 },
|
tableOfContents: toc
|
||||||
{ id: "ipm", title: "Integrated Pest Management (IPM)", level: 1 },
|
? toc.map((item) => ({
|
||||||
{ id: "water-conservation", title: "Water Conservation Techniques", level: 1 },
|
id: item.UUID,
|
||||||
{ id: "soil-health", title: "Soil Health Management", level: 1 },
|
title: item.Title,
|
||||||
{ id: "renewable-energy", title: "Renewable Energy Integration", level: 1 },
|
level: item.Level,
|
||||||
{ id: "conclusion", title: "Conclusion", level: 1 },
|
}))
|
||||||
],
|
: [],
|
||||||
relatedArticles: [
|
relatedArticles: related
|
||||||
{
|
? related.map((item) => ({
|
||||||
id: 2,
|
id: item.UUID,
|
||||||
title: "Optimizing Fertilizer Usage for Maximum Crop Yield",
|
title: item.RelatedTitle,
|
||||||
topic: "Fertilizers",
|
topic: item.RelatedTag,
|
||||||
image: "/placeholder.svg?height=200&width=300",
|
image: "/placeholder.svg",
|
||||||
description: "",
|
}))
|
||||||
date: "",
|
: [],
|
||||||
author: "",
|
};
|
||||||
readTime: "",
|
}
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "Water Conservation Techniques for Drought-Prone Areas",
|
|
||||||
topic: "Sustainability",
|
|
||||||
image: "/placeholder.svg?height=200&width=300",
|
|
||||||
description: "",
|
|
||||||
date: "",
|
|
||||||
author: "",
|
|
||||||
readTime: "",
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Organic Pest Control Methods That Actually Work",
|
|
||||||
topic: "Organic",
|
|
||||||
image: "/placeholder.svg?height=200&width=300",
|
|
||||||
description: "",
|
|
||||||
date: "",
|
|
||||||
author: "",
|
|
||||||
readTime: "",
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
title: "Optimizing Fertilizer Usage for Maximum Crop Yield",
|
|
||||||
description: "Discover the perfect balance of fertilizers to maximize your harvest without wasting resources.",
|
|
||||||
date: "2023-06-02",
|
|
||||||
author: "Michael Chen",
|
|
||||||
topic: "Fertilizers",
|
|
||||||
image: "/placeholder.svg?height=400&width=600",
|
|
||||||
readTime: "7 min read",
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
title: "Seasonal Planting Guide: What to Grow and When",
|
|
||||||
description:
|
|
||||||
"A comprehensive guide to help you plan your planting schedule throughout the year for optimal results.",
|
|
||||||
date: "2023-06-18",
|
|
||||||
author: "Sarah Williams",
|
|
||||||
topic: "Plantation",
|
|
||||||
image: "/placeholder.svg?height=400&width=600",
|
|
||||||
readTime: "8 min read",
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4,
|
|
||||||
title: "Water Conservation Techniques for Drought-Prone Areas",
|
|
||||||
description: "Essential strategies to maintain your crops during water shortages and drought conditions.",
|
|
||||||
date: "2023-07-05",
|
|
||||||
author: "David Rodriguez",
|
|
||||||
topic: "Sustainability",
|
|
||||||
image: "/placeholder.svg?height=400&width=600",
|
|
||||||
readTime: "6 min read",
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
title: "Organic Pest Control Methods That Actually Work",
|
|
||||||
description: "Natural and effective ways to keep pests at bay without resorting to harmful chemicals.",
|
|
||||||
date: "2023-07-22",
|
|
||||||
author: "Lisa Thompson",
|
|
||||||
topic: "Organic",
|
|
||||||
image: "/placeholder.svg?height=400&width=600",
|
|
||||||
readTime: "9 min read",
|
|
||||||
featured: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6,
|
|
||||||
title: "The Future of Smart Farming: IoT and Agriculture",
|
|
||||||
description: "How Internet of Things technology is revolutionizing the way we monitor and manage farms.",
|
|
||||||
date: "2023-08-10",
|
|
||||||
author: "James Wilson",
|
|
||||||
topic: "Technology",
|
|
||||||
image: "/placeholder.svg?height=400&width=600",
|
|
||||||
readTime: "10 min read",
|
|
||||||
featured: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches a list of blog posts.
|
|
||||||
* Simulates a network delay and returns dummy data when the API endpoint is unavailable.
|
|
||||||
*/
|
|
||||||
export async function fetchBlogs(): Promise<Blog[]> {
|
export async function fetchBlogs(): Promise<Blog[]> {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get<Blog[]>("/api/blogs");
|
interface BackendResponse {
|
||||||
return response.data;
|
articles: BackendArticle[];
|
||||||
|
}
|
||||||
|
const response = await axiosInstance.get<BackendResponse>("/knowledge-hub");
|
||||||
|
|
||||||
|
if (response.data && Array.isArray(response.data.articles)) {
|
||||||
|
return response.data.articles.map((article) => mapBackendArticleToFrontendBlog(article));
|
||||||
|
} else {
|
||||||
|
console.warn("Received unexpected data structure from /knowledge-hub:", response.data);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return dummyBlogs;
|
console.error("Error fetching knowledge articles:", error);
|
||||||
|
// Return empty array to avoid breaking the UI completely
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function fetchBlogById(uuid: string): Promise<Blog | null> {
|
||||||
* Fetches a single blog post by its id.
|
|
||||||
* Returns the API result if available; otherwise falls back to dummy data.
|
|
||||||
*/
|
|
||||||
export async function fetchBlogById(id: string): Promise<Blog | null> {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
try {
|
try {
|
||||||
const response = await axiosInstance.get<Blog>(`/api/blogs/${id}`);
|
interface BackendSingleResponse {
|
||||||
return response.data;
|
article: BackendArticle;
|
||||||
|
}
|
||||||
|
const articleResponse = await axiosInstance.get<BackendSingleResponse>(`/knowledge-hub/${uuid}`);
|
||||||
|
|
||||||
|
if (articleResponse.data && articleResponse.data.article) {
|
||||||
|
const article = articleResponse.data.article;
|
||||||
|
|
||||||
|
// --- Fetch TOC and Related separately ---
|
||||||
|
let tocItems: BackendTOC[] = [];
|
||||||
|
let relatedItems: BackendRelated[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tocResponse = await axiosInstance.get<{ table_of_contents: BackendTOC[] }>(`/knowledge-hub/${uuid}/toc`);
|
||||||
|
tocItems = tocResponse.data.table_of_contents || [];
|
||||||
|
} catch (tocError) {
|
||||||
|
console.warn(`Could not fetch TOC for article ${uuid}:`, tocError);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const relatedResponse = await axiosInstance.get<{ related_articles: BackendRelated[] }>(
|
||||||
|
`/knowledge-hub/${uuid}/related`
|
||||||
|
);
|
||||||
|
relatedItems = relatedResponse.data.related_articles || [];
|
||||||
|
} catch (relatedError) {
|
||||||
|
console.warn(`Could not fetch related articles for ${uuid}:`, relatedError);
|
||||||
|
}
|
||||||
|
// --- End separate fetches ---
|
||||||
|
|
||||||
|
return mapBackendArticleToFrontendBlog(article, tocItems, relatedItems);
|
||||||
|
} else {
|
||||||
|
console.warn(`Received unexpected data structure from /knowledge-hub/${uuid}:`, articleResponse.data);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const blog = dummyBlogs.find((blog) => blog.id === Number(id));
|
console.error(`Error fetching knowledge article by UUID ${uuid}:`, error);
|
||||||
return blog || null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,7 @@ export default function BlogPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="border-b sticky top-0 z-10 bg-background/95 backdrop-blur">
|
<header className="border-b sticky top-0 z-10 bg-background/95 backdrop-blur">
|
||||||
<div className="container flex items-center justify-between h-16 px-4">
|
<div className="container flex items-center justify-between h-16 px-4">
|
||||||
<Link href="/knowledge-hub">
|
<Link href="/hub">
|
||||||
<Button variant="ghost" size="sm" className="gap-1">
|
<Button variant="ghost" size="sm" className="gap-1">
|
||||||
<ArrowLeft className="h-4 w-4" /> Back to Knowledge Hub
|
<ArrowLeft className="h-4 w-4" /> Back to Knowledge Hub
|
||||||
</Button>
|
</Button>
|
||||||
@ -162,7 +162,7 @@ export default function BlogPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{blog.relatedArticles.map((article) => (
|
{blog.relatedArticles.map((article) => (
|
||||||
<Link href={`/blog/${article.id}`} key={article.id}>
|
<Link href={`/hub/${article.id}`} key={article.id}>
|
||||||
<div className="flex gap-3 group">
|
<div className="flex gap-3 group">
|
||||||
<div className="relative w-16 h-16 rounded overflow-hidden flex-shrink-0">
|
<div className="relative w-16 h-16 rounded overflow-hidden flex-shrink-0">
|
||||||
<Image
|
<Image
|
||||||
|
|||||||
@ -5,6 +5,14 @@ const nextConfig: NextConfig = {
|
|||||||
devIndicators: {
|
devIndicators: {
|
||||||
buildActivity: false,
|
buildActivity: false,
|
||||||
},
|
},
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: "https",
|
||||||
|
hostname: "**",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@ -168,7 +168,7 @@ export type UpdateInventoryItemInput = Partial<CreateInventoryItemInput> & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface Blog {
|
export interface Blog {
|
||||||
id: number;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
date: string;
|
date: string;
|
||||||
@ -180,7 +180,7 @@ export interface Blog {
|
|||||||
content?: string;
|
content?: string;
|
||||||
tableOfContents?: { id: string; title: string; level: number }[];
|
tableOfContents?: { id: string; title: string; level: number }[];
|
||||||
relatedArticles?: {
|
relatedArticles?: {
|
||||||
id: number;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
topic: string;
|
topic: string;
|
||||||
image: string;
|
image: string;
|
||||||
@ -242,32 +242,54 @@ export interface SetOverlayAction {
|
|||||||
|
|
||||||
export type Action = ActionWithTypeOnly | SetOverlayAction;
|
export type Action = ActionWithTypeOnly | SetOverlayAction;
|
||||||
|
|
||||||
export function isCircle(
|
export function isCircle(overlay: OverlayGeometry): overlay is google.maps.Circle {
|
||||||
overlay: OverlayGeometry
|
|
||||||
): overlay is google.maps.Circle {
|
|
||||||
return (overlay as google.maps.Circle).getCenter !== undefined;
|
return (overlay as google.maps.Circle).getCenter !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isMarker(
|
export function isMarker(overlay: OverlayGeometry): overlay is google.maps.Marker {
|
||||||
overlay: OverlayGeometry
|
|
||||||
): overlay is google.maps.Marker {
|
|
||||||
return (overlay as google.maps.Marker).getPosition !== undefined;
|
return (overlay as google.maps.Marker).getPosition !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPolygon(
|
export function isPolygon(overlay: OverlayGeometry): overlay is google.maps.Polygon {
|
||||||
overlay: OverlayGeometry
|
|
||||||
): overlay is google.maps.Polygon {
|
|
||||||
return (overlay as google.maps.Polygon).getPath !== undefined;
|
return (overlay as google.maps.Polygon).getPath !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isPolyline(
|
export function isPolyline(overlay: OverlayGeometry): overlay is google.maps.Polyline {
|
||||||
overlay: OverlayGeometry
|
|
||||||
): overlay is google.maps.Polyline {
|
|
||||||
return (overlay as google.maps.Polyline).getPath !== undefined;
|
return (overlay as google.maps.Polyline).getPath !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRectangle(
|
export function isRectangle(overlay: OverlayGeometry): overlay is google.maps.Rectangle {
|
||||||
overlay: OverlayGeometry
|
|
||||||
): overlay is google.maps.Rectangle {
|
|
||||||
return (overlay as google.maps.Rectangle).getBounds !== undefined;
|
return (overlay as google.maps.Rectangle).getBounds !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeArticle {
|
||||||
|
UUID: string;
|
||||||
|
Title: string;
|
||||||
|
Content: string;
|
||||||
|
Author: string;
|
||||||
|
PublishDate: string;
|
||||||
|
ReadTime: string;
|
||||||
|
Categories: string[];
|
||||||
|
ImageURL: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
UpdatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableOfContent {
|
||||||
|
UUID: string;
|
||||||
|
ArticleID: string;
|
||||||
|
Title: string;
|
||||||
|
Level: number;
|
||||||
|
Order: number;
|
||||||
|
CreatedAt: string;
|
||||||
|
UpdatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelatedArticle {
|
||||||
|
UUID: string;
|
||||||
|
ArticleID: string;
|
||||||
|
RelatedTitle: string;
|
||||||
|
RelatedTag: string;
|
||||||
|
CreatedAt: string;
|
||||||
|
UpdatedAt: string;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user