Merge branch 'main' into feature-test

This commit is contained in:
THIS ONE IS A LITTLE BIT TRICKY KRUB 2025-04-04 21:57:41 +07:00
commit c0a8a8a6be
13 changed files with 334 additions and 175 deletions

View File

@ -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
View 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
View 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)
}

View File

@ -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)

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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
} }

View File

@ -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;
} }
} }

View File

@ -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

View File

@ -5,6 +5,14 @@ const nextConfig: NextConfig = {
devIndicators: { devIndicators: {
buildActivity: false, buildActivity: false,
}, },
images: {
remotePatterns: [
{
protocol: "https",
hostname: "**",
},
],
},
}; };
export default nextConfig; export default nextConfig;

View File

@ -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;
}