diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index e092343..957e4e2 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -16,6 +16,7 @@ import ( "github.com/go-chi/cors" "github.com/jackc/pgx/v5/pgxpool" + "github.com/forfarm/backend/internal/cache" "github.com/forfarm/backend/internal/config" "github.com/forfarm/backend/internal/domain" m "github.com/forfarm/backend/internal/middlewares" @@ -29,6 +30,7 @@ type api struct { logger *slog.Logger httpClient *http.Client eventPublisher domain.EventPublisher + cache cache.Cache userRepo domain.UserRepository cropRepo domain.CroplandRepository @@ -54,17 +56,21 @@ func NewAPI( pool *pgxpool.Pool, eventPublisher domain.EventPublisher, analyticsRepo domain.AnalyticsRepository, - inventoryRepo domain.InventoryRepository, - croplandRepo domain.CroplandRepository, farmRepo domain.FarmRepository, ) *api { client := &http.Client{} + logger.Info("creating memory cache") + memoryCache := cache.NewMemoryCache(1*time.Hour, 2*time.Hour) + 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) - harvestRepository := repository.NewPostgresHarvest(pool) + croplandRepo := repository.NewPostgresCropland(pool) + croplandRepo.SetEventPublisher(eventPublisher) owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger) cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL) @@ -88,6 +94,7 @@ func NewAPI( logger: logger, httpClient: client, eventPublisher: eventPublisher, + cache: memoryCache, userRepo: userRepository, cropRepo: croplandRepo, diff --git a/backend/internal/cache/cache.go b/backend/internal/cache/cache.go new file mode 100644 index 0000000..fc7e1bc --- /dev/null +++ b/backend/internal/cache/cache.go @@ -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 +) diff --git a/backend/internal/cache/memory_cache.go b/backend/internal/cache/memory_cache.go new file mode 100644 index 0000000..c98b4e3 --- /dev/null +++ b/backend/internal/cache/memory_cache.go @@ -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) +} diff --git a/backend/internal/cmd/api.go b/backend/internal/cmd/api.go index 400c6ac..0095a5d 100644 --- a/backend/internal/cmd/api.go +++ b/backend/internal/cmd/api.go @@ -55,11 +55,6 @@ func APICmd(ctx context.Context) *cobra.Command { farmRepo := repository.NewPostgresFarm(pool) farmRepo.SetEventPublisher(eventBus) - inventoryRepo := repository.NewPostgresInventory(pool, eventBus) - - croplandRepo := repository.NewPostgresCropland(pool) - croplandRepo.SetEventPublisher(eventBus) - projection := event.NewFarmAnalyticsProjection(eventBus, analyticsRepo, logger) go func() { if err := projection.Start(ctx); err != nil { @@ -68,7 +63,7 @@ func APICmd(ctx context.Context) *cobra.Command { }() 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() weatherInterval, err := time.ParseDuration(config.WEATHER_FETCH_INTERVAL) diff --git a/backend/internal/repository/postgres_harvest.go b/backend/internal/repository/postgres_harvest.go index 3bfc40a..9ab2999 100644 --- a/backend/internal/repository/postgres_harvest.go +++ b/backend/internal/repository/postgres_harvest.go @@ -2,19 +2,34 @@ package repository import ( "context" + "log/slog" + "github.com/forfarm/backend/internal/cache" "github.com/forfarm/backend/internal/domain" ) +const ( + cacheKeyHarvestUnits = "harvest:units" +) + type postgresHarvestRepository struct { - conn Connection + conn Connection + cache cache.Cache } -func NewPostgresHarvest(conn Connection) domain.HarvestRepository { - return &postgresHarvestRepository{conn: conn} +func NewPostgresHarvest(conn Connection, c cache.Cache) domain.HarvestRepository { + return &postgresHarvestRepository{conn: conn, cache: c} } 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` rows, err := p.conn.Query(ctx, query) if err != nil { @@ -30,5 +45,13 @@ func (p *postgresHarvestRepository) GetUnits(ctx context.Context) ([]domain.Harv } 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 } diff --git a/backend/internal/repository/postgres_inventory.go b/backend/internal/repository/postgres_inventory.go index f5ef4bf..574058d 100644 --- a/backend/internal/repository/postgres_inventory.go +++ b/backend/internal/repository/postgres_inventory.go @@ -3,20 +3,28 @@ package repository import ( "context" "fmt" + "log/slog" "strings" "time" + "github.com/forfarm/backend/internal/cache" "github.com/forfarm/backend/internal/domain" "github.com/google/uuid" ) +const ( + cacheKeyInventoryStatuses = "inventory:statuses" + cacheKeyInventoryCategories = "inventory:categories" +) + type postgresInventoryRepository struct { conn Connection eventPublisher domain.EventPublisher + cache cache.Cache } -func NewPostgresInventory(conn Connection, publisher domain.EventPublisher) domain.InventoryRepository { - return &postgresInventoryRepository{conn: conn, eventPublisher: publisher} +func NewPostgresInventory(conn Connection, publisher domain.EventPublisher, c cache.Cache) domain.InventoryRepository { + return &postgresInventoryRepository{conn: conn, eventPublisher: publisher, cache: c} } 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) { + 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` rows, err := p.conn.Query(ctx, query) if err != nil { @@ -357,10 +373,26 @@ func (p *postgresInventoryRepository) GetStatuses(ctx context.Context) ([]domain } 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 } 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` rows, err := p.conn.Query(ctx, query) if err != nil { @@ -376,5 +408,13 @@ func (p *postgresInventoryRepository) GetCategories(ctx context.Context) ([]doma } 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 } diff --git a/backend/internal/repository/postgres_plant.go b/backend/internal/repository/postgres_plant.go index bb4eb38..ed96ef2 100644 --- a/backend/internal/repository/postgres_plant.go +++ b/backend/internal/repository/postgres_plant.go @@ -2,18 +2,28 @@ package repository import ( "context" + "log/slog" "strings" + "time" + "github.com/forfarm/backend/internal/cache" "github.com/forfarm/backend/internal/domain" "github.com/google/uuid" ) +const ( + cacheKeyPlantsAll = "plants:all" + cacheKeyPlantPrefix = "plant:uuid:" + cacheTTLStatic = 1 * time.Hour // Cache static lists for 1 hour +) + type postgresPlantRepository struct { - conn Connection + conn Connection + cache cache.Cache } -func NewPostgresPlant(conn Connection) domain.PlantRepository { - return &postgresPlantRepository{conn: conn} +func NewPostgresPlant(conn Connection, c cache.Cache) domain.PlantRepository { + return &postgresPlantRepository{conn: conn, cache: c} } 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) { + // 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` 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 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) { @@ -61,8 +86,25 @@ func (p *postgresPlantRepository) GetByName(ctx context.Context, name string) (d } 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` - 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 { @@ -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) 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 { @@ -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, 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) + 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 } func (p *postgresPlantRepository) Delete(ctx context.Context, uuid string) error { query := `DELETE FROM plants WHERE uuid = $1` _, 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 } diff --git a/backend/migrations/00004_create_knowledge_hub_tables.sql b/backend/migrations/000015_create_knowledge_hub_tables.sql similarity index 100% rename from backend/migrations/00004_create_knowledge_hub_tables.sql rename to backend/migrations/000015_create_knowledge_hub_tables.sql diff --git a/backend/migrations/00005_add_image_url_to_articles.sql b/backend/migrations/000016_add_image_url_to_articles.sql similarity index 100% rename from backend/migrations/00005_add_image_url_to_articles.sql rename to backend/migrations/000016_add_image_url_to_articles.sql diff --git a/frontend/api/hub.ts b/frontend/api/hub.ts index 4ed0325..6856afa 100644 --- a/frontend/api/hub.ts +++ b/frontend/api/hub.ts @@ -1,148 +1,103 @@ import axiosInstance from "./config"; +import type { + KnowledgeArticle as BackendArticle, + TableOfContent as BackendTOC, + RelatedArticle as BackendRelated, +} from "@/types"; import type { Blog } from "@/types"; -// Dummy blog data used as a fallback. -const dummyBlogs: Blog[] = [ - { - id: 1, - title: "Sustainable Farming Practices for Modern Agriculture", - description: - "Learn about eco-friendly farming techniques that can increase yield while preserving the environment.", - date: "2023-05-15", - author: "Emma Johnson", - topic: "Sustainability", - image: "/placeholder.svg?height=400&width=600", - readTime: "5 min read", - featured: true, - content: `

Sustainable farming is not just a trend; it's a necessary evolution in agricultural practices. […]

`, - tableOfContents: [ - { id: "importance", title: "The Importance of Sustainable Agriculture", level: 1 }, - { id: "crop-rotation", title: "Crop Rotation and Diversification", level: 1 }, - { id: "ipm", title: "Integrated Pest Management (IPM)", level: 1 }, - { id: "water-conservation", title: "Water Conservation Techniques", level: 1 }, - { id: "soil-health", title: "Soil Health Management", level: 1 }, - { id: "renewable-energy", title: "Renewable Energy Integration", level: 1 }, - { id: "conclusion", title: "Conclusion", level: 1 }, - ], - relatedArticles: [ - { - id: 2, - title: "Optimizing Fertilizer Usage for Maximum Crop Yield", - topic: "Fertilizers", - image: "/placeholder.svg?height=200&width=300", - 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, - }, -]; +function mapBackendArticleToFrontendBlog( + backendArticle: BackendArticle, + toc?: BackendTOC[], + related?: BackendRelated[] +): Blog { + return { + id: backendArticle.UUID, + title: backendArticle.Title, + description: backendArticle.Content.substring(0, 150) + "...", + date: backendArticle.PublishDate.toString(), + author: backendArticle.Author, + topic: backendArticle.Categories.length > 0 ? backendArticle.Categories[0] : "General", + image: backendArticle.ImageURL || "/placeholder.svg", + readTime: backendArticle.ReadTime || "5 min read", + featured: backendArticle.Categories.includes("Featured"), + content: backendArticle.Content, + tableOfContents: toc + ? toc.map((item) => ({ + id: item.UUID, + title: item.Title, + level: item.Level, + })) + : [], + relatedArticles: related + ? related.map((item) => ({ + id: item.UUID, + title: item.RelatedTitle, + topic: item.RelatedTag, + image: "/placeholder.svg", + })) + : [], + }; +} -/** - * 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 { - await new Promise((resolve) => setTimeout(resolve, 1000)); try { - const response = await axiosInstance.get("/api/blogs"); - return response.data; + interface BackendResponse { + articles: BackendArticle[]; + } + const response = await axiosInstance.get("/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) { - return dummyBlogs; + console.error("Error fetching knowledge articles:", error); + // Return empty array to avoid breaking the UI completely + return []; } } -/** - * 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 { - await new Promise((resolve) => setTimeout(resolve, 500)); +export async function fetchBlogById(uuid: string): Promise { try { - const response = await axiosInstance.get(`/api/blogs/${id}`); - return response.data; + interface BackendSingleResponse { + article: BackendArticle; + } + const articleResponse = await axiosInstance.get(`/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) { - const blog = dummyBlogs.find((blog) => blog.id === Number(id)); - return blog || null; + console.error(`Error fetching knowledge article by UUID ${uuid}:`, error); + return null; } } diff --git a/frontend/app/(sidebar)/hub/[id]/page.tsx b/frontend/app/(sidebar)/hub/[id]/page.tsx index 497e57a..9bbf9be 100644 --- a/frontend/app/(sidebar)/hub/[id]/page.tsx +++ b/frontend/app/(sidebar)/hub/[id]/page.tsx @@ -67,7 +67,7 @@ export default function BlogPage() { {/* Header */}
- + @@ -162,7 +162,7 @@ export default function BlogPage() {
{blog.relatedArticles.map((article) => ( - +
& { }; export interface Blog { - id: number; + id: string; title: string; description: string; date: string; @@ -180,7 +180,7 @@ export interface Blog { content?: string; tableOfContents?: { id: string; title: string; level: number }[]; relatedArticles?: { - id: number; + id: string; title: string; topic: string; image: string; @@ -242,32 +242,54 @@ export interface SetOverlayAction { export type Action = ActionWithTypeOnly | SetOverlayAction; -export function isCircle( - overlay: OverlayGeometry -): overlay is google.maps.Circle { +export function isCircle(overlay: OverlayGeometry): overlay is google.maps.Circle { return (overlay as google.maps.Circle).getCenter !== undefined; } -export function isMarker( - overlay: OverlayGeometry -): overlay is google.maps.Marker { +export function isMarker(overlay: OverlayGeometry): overlay is google.maps.Marker { return (overlay as google.maps.Marker).getPosition !== undefined; } -export function isPolygon( - overlay: OverlayGeometry -): overlay is google.maps.Polygon { +export function isPolygon(overlay: OverlayGeometry): overlay is google.maps.Polygon { return (overlay as google.maps.Polygon).getPath !== undefined; } -export function isPolyline( - overlay: OverlayGeometry -): overlay is google.maps.Polyline { +export function isPolyline(overlay: OverlayGeometry): overlay is google.maps.Polyline { return (overlay as google.maps.Polyline).getPath !== undefined; } -export function isRectangle( - overlay: OverlayGeometry -): overlay is google.maps.Rectangle { +export function isRectangle(overlay: OverlayGeometry): overlay is google.maps.Rectangle { 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; +}