From 0d2b38110af629a4a1eab56f0b1192a48dec3ad9 Mon Sep 17 00:00:00 2001 From: Buravit Yenjit Date: Tue, 1 Apr 2025 13:39:25 +0700 Subject: [PATCH 1/7] feat: add knowledgeHub domain --- backend/internal/domain/knowledgeHub.go | 58 +++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 backend/internal/domain/knowledgeHub.go diff --git a/backend/internal/domain/knowledgeHub.go b/backend/internal/domain/knowledgeHub.go new file mode 100644 index 0000000..3a8a39f --- /dev/null +++ b/backend/internal/domain/knowledgeHub.go @@ -0,0 +1,58 @@ +package domain + +import ( + "context" + "time" + + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type KnowledgeArticle struct { + UUID string + Title string + Content string + Author string + PublishDate time.Time + ReadTime string + Categories []string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (k *KnowledgeArticle) Validate() error { + return validation.ValidateStruct(k, + validation.Field(&k.Title, validation.Required), + validation.Field(&k.Content, validation.Required), + validation.Field(&k.Author, validation.Required), + validation.Field(&k.PublishDate, validation.Required), + ) +} + +type TableOfContent struct { + UUID string + ArticleID string + Title string + Order int + CreatedAt time.Time + UpdatedAt time.Time +} + +type RelatedArticle struct { + UUID string + ArticleID string + RelatedTitle string + RelatedTag string + CreatedAt time.Time + UpdatedAt time.Time +} + +type KnowledgeHubRepository interface { + GetArticleByID(context.Context, string) (KnowledgeArticle, error) + GetArticlesByCategory(ctx context.Context, category string) ([]KnowledgeArticle, error) + GetAllArticles(ctx context.Context) ([]KnowledgeArticle, error) + CreateOrUpdateArticle(context.Context, *KnowledgeArticle) error + DeleteArticle(context.Context, string) error + + GetTableOfContents(ctx context.Context, articleID string) ([]TableOfContent, error) + GetRelatedArticles(ctx context.Context, articleID string) ([]RelatedArticle, error) +} From b336d942774c55521cb5828a3f4af4b3bd9ba66f Mon Sep 17 00:00:00 2001 From: Buravit Yenjit Date: Tue, 1 Apr 2025 13:40:22 +0700 Subject: [PATCH 2/7] feat: add knowledgeHub repository --- .../repository/postgres_knowledgeHub.go | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 backend/internal/repository/postgres_knowledgeHub.go diff --git a/backend/internal/repository/postgres_knowledgeHub.go b/backend/internal/repository/postgres_knowledgeHub.go new file mode 100644 index 0000000..8e4acb9 --- /dev/null +++ b/backend/internal/repository/postgres_knowledgeHub.go @@ -0,0 +1,184 @@ +package repository + +import ( + "context" + "strings" + + "github.com/forfarm/backend/internal/domain" + "github.com/google/uuid" +) + +type postgresKnowledgeHubRepository struct { + conn Connection +} + +func NewPostgresKnowledgeHub(conn Connection) domain.KnowledgeHubRepository { + return &postgresKnowledgeHubRepository{conn: conn} +} + +func (p *postgresKnowledgeHubRepository) fetchArticles(ctx context.Context, query string, args ...interface{}) ([]domain.KnowledgeArticle, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var articles []domain.KnowledgeArticle + for rows.Next() { + var a domain.KnowledgeArticle + if err := rows.Scan( + &a.UUID, + &a.Title, + &a.Content, + &a.Author, + &a.PublishDate, + &a.ReadTime, + &a.Categories, + &a.CreatedAt, + &a.UpdatedAt, + ); err != nil { + return nil, err + } + articles = append(articles, a) + } + return articles, nil +} + +func (p *postgresKnowledgeHubRepository) GetArticleByID(ctx context.Context, uuid string) (domain.KnowledgeArticle, error) { + query := ` + SELECT uuid, title, content, author, publish_date, read_time, categories, created_at, updated_at + FROM knowledge_articles + WHERE uuid = $1` + + articles, err := p.fetchArticles(ctx, query, uuid) + if err != nil { + return domain.KnowledgeArticle{}, err + } + if len(articles) == 0 { + return domain.KnowledgeArticle{}, domain.ErrNotFound + } + return articles[0], nil +} + +func (p *postgresKnowledgeHubRepository) GetArticlesByCategory(ctx context.Context, category string) ([]domain.KnowledgeArticle, error) { + query := ` + SELECT uuid, title, content, author, publish_date, read_time, categories, created_at, updated_at + FROM knowledge_articles + WHERE $1 = ANY(categories)` + + return p.fetchArticles(ctx, query, category) +} + +func (p *postgresKnowledgeHubRepository) GetAllArticles(ctx context.Context) ([]domain.KnowledgeArticle, error) { + query := ` + SELECT uuid, title, content, author, publish_date, read_time, categories, created_at, updated_at + FROM knowledge_articles` + + return p.fetchArticles(ctx, query) +} + +func (p *postgresKnowledgeHubRepository) CreateOrUpdateArticle(ctx context.Context, article *domain.KnowledgeArticle) error { + if strings.TrimSpace(article.UUID) == "" { + article.UUID = uuid.New().String() + } + + query := ` + INSERT INTO knowledge_articles (uuid, title, content, author, publish_date, read_time, categories, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + ON CONFLICT (uuid) DO UPDATE + SET title = EXCLUDED.title, + content = EXCLUDED.content, + author = EXCLUDED.author, + publish_date = EXCLUDED.publish_date, + read_time = EXCLUDED.read_time, + categories = EXCLUDED.categories, + updated_at = NOW() + RETURNING uuid, created_at, updated_at` + + return p.conn.QueryRow( + ctx, + query, + article.UUID, + article.Title, + article.Content, + article.Author, + article.PublishDate, + article.ReadTime, + article.Categories, + ).Scan(&article.UUID, &article.CreatedAt, &article.UpdatedAt) +} + +func (p *postgresKnowledgeHubRepository) DeleteArticle(ctx context.Context, uuid string) error { + query := `DELETE FROM knowledge_articles WHERE uuid = $1` + _, err := p.conn.Exec(ctx, query, uuid) + return err +} + +func (p *postgresKnowledgeHubRepository) fetchTableOfContents(ctx context.Context, query string, args ...interface{}) ([]domain.TableOfContent, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var tocItems []domain.TableOfContent + for rows.Next() { + var t domain.TableOfContent + if err := rows.Scan( + &t.UUID, + &t.ArticleID, + &t.Title, + &t.Order, + &t.CreatedAt, + &t.UpdatedAt, + ); err != nil { + return nil, err + } + tocItems = append(tocItems, t) + } + return tocItems, nil +} + +func (p *postgresKnowledgeHubRepository) GetTableOfContents(ctx context.Context, articleID string) ([]domain.TableOfContent, error) { + query := ` + SELECT uuid, article_id, title, "order", created_at, updated_at + FROM table_of_contents + WHERE article_id = $1 + ORDER BY "order" ASC` + + return p.fetchTableOfContents(ctx, query, articleID) +} + +func (p *postgresKnowledgeHubRepository) fetchRelatedArticles(ctx context.Context, query string, args ...interface{}) ([]domain.RelatedArticle, error) { + rows, err := p.conn.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var relatedArticles []domain.RelatedArticle + for rows.Next() { + var r domain.RelatedArticle + if err := rows.Scan( + &r.UUID, + &r.ArticleID, + &r.RelatedTitle, + &r.RelatedTag, + &r.CreatedAt, + &r.UpdatedAt, + ); err != nil { + return nil, err + } + relatedArticles = append(relatedArticles, r) + } + return relatedArticles, nil +} + +func (p *postgresKnowledgeHubRepository) GetRelatedArticles(ctx context.Context, articleID string) ([]domain.RelatedArticle, error) { + query := ` + SELECT uuid, article_id, related_title, related_tag, created_at, updated_at + FROM related_articles + WHERE article_id = $1` + + return p.fetchRelatedArticles(ctx, query, articleID) +} From 38f01d0fbeb3a10405441122566df2b82ceb8198 Mon Sep 17 00:00:00 2001 From: Buravit Yenjit Date: Tue, 1 Apr 2025 13:41:06 +0700 Subject: [PATCH 3/7] feat: add knowledgeHub api --- backend/internal/api/api.go | 20 ++- backend/internal/api/knowledgeHub.go | 253 +++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 8 deletions(-) create mode 100644 backend/internal/api/knowledgeHub.go diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 3f9b560..8053387 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -22,10 +22,11 @@ type api struct { logger *slog.Logger httpClient *http.Client - userRepo domain.UserRepository - cropRepo domain.CroplandRepository - farmRepo domain.FarmRepository - plantRepo domain.PlantRepository + userRepo domain.UserRepository + cropRepo domain.CroplandRepository + farmRepo domain.FarmRepository + plantRepo domain.PlantRepository + knowledgeHubRepo domain.KnowledgeHubRepository } func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api { @@ -36,15 +37,17 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api { croplandRepository := repository.NewPostgresCropland(pool) farmRepository := repository.NewPostgresFarm(pool) plantRepository := repository.NewPostgresPlant(pool) + knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool) return &api{ logger: logger, httpClient: client, - userRepo: userRepository, - cropRepo: croplandRepository, - farmRepo: farmRepository, - plantRepo: plantRepository, + userRepo: userRepository, + cropRepo: croplandRepository, + farmRepo: farmRepository, + plantRepo: plantRepository, + knowledgeHubRepo: knowledgeHubRepository, } } @@ -77,6 +80,7 @@ func (a *api) Routes() *chi.Mux { a.registerCropRoutes(r, api) a.registerPlantRoutes(r, api) a.registerOauthRoutes(r, api) + a.registerKnowledgeHubRoutes(r, api) }) router.Group(func(r chi.Router) { diff --git a/backend/internal/api/knowledgeHub.go b/backend/internal/api/knowledgeHub.go new file mode 100644 index 0000000..cf17cde --- /dev/null +++ b/backend/internal/api/knowledgeHub.go @@ -0,0 +1,253 @@ +package api + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/danielgtaylor/huma/v2" + "github.com/forfarm/backend/internal/domain" + "github.com/go-chi/chi/v5" + "github.com/gofrs/uuid" +) + +func (a *api) registerKnowledgeHubRoutes(_ chi.Router, api huma.API) { + tags := []string{"knowledge-hub"} + + prefix := "/knowledge-hub" + + huma.Register(api, huma.Operation{ + OperationID: "getAllKnowledgeArticles", + Method: http.MethodGet, + Path: prefix, + Tags: tags, + }, a.getAllKnowledgeArticlesHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getKnowledgeArticleByID", + Method: http.MethodGet, + Path: prefix + "/{uuid}", + Tags: tags, + }, a.getKnowledgeArticleByIDHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getKnowledgeArticlesByCategory", + Method: http.MethodGet, + Path: prefix + "/category/{category}", + Tags: tags, + }, a.getKnowledgeArticlesByCategoryHandler) + + huma.Register(api, huma.Operation{ + OperationID: "createOrUpdateKnowledgeArticle", + Method: http.MethodPost, + Path: prefix, + Tags: tags, + }, a.createOrUpdateKnowledgeArticleHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getArticleTableOfContents", + Method: http.MethodGet, + Path: prefix + "/{uuid}/toc", + Tags: tags, + }, a.getArticleTableOfContentsHandler) + + huma.Register(api, huma.Operation{ + OperationID: "getArticleRelatedArticles", + Method: http.MethodGet, + Path: prefix + "/{uuid}/related", + Tags: tags, + }, a.getArticleRelatedArticlesHandler) +} + +type GetKnowledgeArticlesOutput struct { + Body struct { + Articles []domain.KnowledgeArticle `json:"articles"` + } `json:"body"` +} + +type GetKnowledgeArticleByIDOutput struct { + Body struct { + Article domain.KnowledgeArticle `json:"article"` + } `json:"body"` +} + +type CreateOrUpdateKnowledgeArticleInput struct { + Body struct { + UUID string `json:"uuid,omitempty"` // Optional for create, required for update + Title string `json:"title"` + Content string `json:"content"` + Author string `json:"author"` + PublishDate time.Time `json:"publish_date"` + ReadTime string `json:"read_time"` + Categories []string `json:"categories"` + } `json:"body"` +} + +type CreateOrUpdateKnowledgeArticleOutput struct { + Body struct { + Article domain.KnowledgeArticle `json:"article"` + } `json:"body"` +} + +type GetTableOfContentsOutput struct { + Body struct { + TableOfContents []domain.TableOfContent `json:"table_of_contents"` + } `json:"body"` +} + +type GetRelatedArticlesOutput struct { + Body struct { + RelatedArticles []domain.RelatedArticle `json:"related_articles"` + } `json:"body"` +} + +func (a *api) getAllKnowledgeArticlesHandler(ctx context.Context, input *struct{}) (*GetKnowledgeArticlesOutput, error) { + resp := &GetKnowledgeArticlesOutput{} + + articles, err := a.knowledgeHubRepo.GetAllArticles(ctx) + if err != nil { + return nil, err + } + + resp.Body.Articles = articles + return resp, nil +} + +func (a *api) getKnowledgeArticleByIDHandler(ctx context.Context, input *struct { + UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` +}) (*GetKnowledgeArticleByIDOutput, error) { + resp := &GetKnowledgeArticleByIDOutput{} + + if input.UUID == "" { + return nil, huma.Error400BadRequest("UUID parameter is required") + } + + _, err := uuid.FromString(input.UUID) + if err != nil { + return nil, huma.Error400BadRequest("invalid UUID format") + } + + article, err := a.knowledgeHubRepo.GetArticleByID(ctx, input.UUID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return nil, huma.Error404NotFound("article not found") + } + return nil, err + } + + resp.Body.Article = article + return resp, nil +} + +func (a *api) getKnowledgeArticlesByCategoryHandler(ctx context.Context, input *struct { + Category string `path:"category" example:"Sustainability"` +}) (*GetKnowledgeArticlesOutput, error) { + resp := &GetKnowledgeArticlesOutput{} + + if input.Category == "" { + return nil, huma.Error400BadRequest("category parameter is required") + } + + articles, err := a.knowledgeHubRepo.GetArticlesByCategory(ctx, input.Category) + if err != nil { + return nil, err + } + + resp.Body.Articles = articles + return resp, nil +} + +func (a *api) createOrUpdateKnowledgeArticleHandler(ctx context.Context, input *CreateOrUpdateKnowledgeArticleInput) (*CreateOrUpdateKnowledgeArticleOutput, error) { + resp := &CreateOrUpdateKnowledgeArticleOutput{} + + if input.Body.Title == "" { + return nil, huma.Error400BadRequest("title is required") + } + if input.Body.Content == "" { + return nil, huma.Error400BadRequest("content is required") + } + if input.Body.Author == "" { + return nil, huma.Error400BadRequest("author is required") + } + if len(input.Body.Categories) == 0 { + return nil, huma.Error400BadRequest("at least one category is required") + } + + if input.Body.UUID != "" { + _, err := uuid.FromString(input.Body.UUID) + if err != nil { + return nil, huma.Error400BadRequest("invalid UUID format") + } + } + + article := &domain.KnowledgeArticle{ + UUID: input.Body.UUID, + Title: input.Body.Title, + Content: input.Body.Content, + Author: input.Body.Author, + PublishDate: input.Body.PublishDate, + ReadTime: input.Body.ReadTime, + Categories: input.Body.Categories, + } + + err := a.knowledgeHubRepo.CreateOrUpdateArticle(ctx, article) + if err != nil { + return nil, err + } + + resp.Body.Article = *article + return resp, nil +} + +func (a *api) getArticleTableOfContentsHandler(ctx context.Context, input *struct { + UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` +}) (*GetTableOfContentsOutput, error) { + resp := &GetTableOfContentsOutput{} + + if input.UUID == "" { + return nil, huma.Error400BadRequest("UUID parameter is required") + } + + _, err := uuid.FromString(input.UUID) + if err != nil { + return nil, huma.Error400BadRequest("invalid UUID format") + } + + toc, err := a.knowledgeHubRepo.GetTableOfContents(ctx, input.UUID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return nil, huma.Error404NotFound("article not found") + } + return nil, err + } + + resp.Body.TableOfContents = toc + return resp, nil +} + +func (a *api) getArticleRelatedArticlesHandler(ctx context.Context, input *struct { + UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` +}) (*GetRelatedArticlesOutput, error) { + resp := &GetRelatedArticlesOutput{} + + if input.UUID == "" { + return nil, huma.Error400BadRequest("UUID parameter is required") + } + + _, err := uuid.FromString(input.UUID) + if err != nil { + return nil, huma.Error400BadRequest("invalid UUID format") + } + + related, err := a.knowledgeHubRepo.GetRelatedArticles(ctx, input.UUID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return nil, huma.Error404NotFound("article not found") + } + return nil, err + } + + resp.Body.RelatedArticles = related + return resp, nil +} From dd86f38b5a03b2890491e6519d8233e86dd4a3c3 Mon Sep 17 00:00:00 2001 From: Buravit Yenjit Date: Tue, 1 Apr 2025 13:42:07 +0700 Subject: [PATCH 4/7] feat: add migration for knowledgeHub --- .../00004_create_knowledge_hub_tables.sql | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 backend/migrations/00004_create_knowledge_hub_tables.sql diff --git a/backend/migrations/00004_create_knowledge_hub_tables.sql b/backend/migrations/00004_create_knowledge_hub_tables.sql new file mode 100644 index 0000000..4d8b722 --- /dev/null +++ b/backend/migrations/00004_create_knowledge_hub_tables.sql @@ -0,0 +1,41 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE knowledge_articles ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title TEXT NOT NULL, + content TEXT NOT NULL, + author TEXT NOT NULL, + publish_date TIMESTAMPTZ NOT NULL, + read_time TEXT NOT NULL, + categories TEXT[] NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE table_of_contents ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL, + title TEXT NOT NULL, + "order" INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_toc_article FOREIGN KEY (article_id) REFERENCES knowledge_articles(uuid) ON DELETE CASCADE +); + +CREATE TABLE related_articles ( + uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL, + related_title TEXT NOT NULL, + related_tag TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_related_article FOREIGN KEY (article_id) REFERENCES knowledge_articles(uuid) ON DELETE CASCADE +); +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS related_articles; +DROP TABLE IF EXISTS table_of_contents; +DROP TABLE IF EXISTS knowledge_articles; +-- +goose StatementEnd \ No newline at end of file From 8af97c0150925b5a26891033f443c0c9b703252c Mon Sep 17 00:00:00 2001 From: Buravit Yenjit Date: Tue, 1 Apr 2025 14:52:47 +0700 Subject: [PATCH 5/7] feat: add CreateRelatedArticle --- backend/internal/api/knowledgeHub.go | 37 +++++++++++++++++++ backend/internal/domain/knowledgeHub.go | 1 + .../repository/postgres_knowledgeHub.go | 24 ++++++++++++ 3 files changed, 62 insertions(+) diff --git a/backend/internal/api/knowledgeHub.go b/backend/internal/api/knowledgeHub.go index cf17cde..f0eb603 100644 --- a/backend/internal/api/knowledgeHub.go +++ b/backend/internal/api/knowledgeHub.go @@ -58,6 +58,13 @@ func (a *api) registerKnowledgeHubRoutes(_ chi.Router, api huma.API) { Path: prefix + "/{uuid}/related", Tags: tags, }, a.getArticleRelatedArticlesHandler) + + huma.Register(api, huma.Operation{ + OperationID: "createRelatedArticle", + Method: http.MethodPost, + Path: prefix + "/{uuid}/related", + Tags: tags, + }, a.createRelatedArticleHandler) } type GetKnowledgeArticlesOutput struct { @@ -102,6 +109,14 @@ type GetRelatedArticlesOutput struct { } `json:"body"` } +type CreateRelatedArticleInput struct { + UUID string `path:"uuid"` + Body struct { + RelatedTitle string `json:"related_title"` + RelatedTag string `json:"related_tag"` + } `json:"body"` +} + func (a *api) getAllKnowledgeArticlesHandler(ctx context.Context, input *struct{}) (*GetKnowledgeArticlesOutput, error) { resp := &GetKnowledgeArticlesOutput{} @@ -251,3 +266,25 @@ func (a *api) getArticleRelatedArticlesHandler(ctx context.Context, input *struc resp.Body.RelatedArticles = related return resp, nil } + +func (a *api) createRelatedArticleHandler( + ctx context.Context, + input *CreateRelatedArticleInput, +) (*struct{}, error) { + // Validate main article exists + if _, err := a.knowledgeHubRepo.GetArticleByID(ctx, input.UUID); err != nil { + return nil, huma.Error404NotFound("main article not found") + } + + // Create related article + related := &domain.RelatedArticle{ + RelatedTitle: input.Body.RelatedTitle, + RelatedTag: input.Body.RelatedTag, + } + + if err := a.knowledgeHubRepo.CreateRelatedArticle(ctx, input.UUID, related); err != nil { + return nil, huma.Error500InternalServerError("failed to create related article") + } + + return nil, nil // HTTP 204 No Content +} diff --git a/backend/internal/domain/knowledgeHub.go b/backend/internal/domain/knowledgeHub.go index 3a8a39f..45b4b22 100644 --- a/backend/internal/domain/knowledgeHub.go +++ b/backend/internal/domain/knowledgeHub.go @@ -55,4 +55,5 @@ type KnowledgeHubRepository interface { GetTableOfContents(ctx context.Context, articleID string) ([]TableOfContent, error) GetRelatedArticles(ctx context.Context, articleID string) ([]RelatedArticle, error) + CreateRelatedArticle(ctx context.Context, articleID string, related *RelatedArticle) error } diff --git a/backend/internal/repository/postgres_knowledgeHub.go b/backend/internal/repository/postgres_knowledgeHub.go index 8e4acb9..8e5018c 100644 --- a/backend/internal/repository/postgres_knowledgeHub.go +++ b/backend/internal/repository/postgres_knowledgeHub.go @@ -182,3 +182,27 @@ func (p *postgresKnowledgeHubRepository) GetRelatedArticles(ctx context.Context, return p.fetchRelatedArticles(ctx, query, articleID) } + +func (p *postgresKnowledgeHubRepository) CreateRelatedArticle( + ctx context.Context, + articleID string, + related *domain.RelatedArticle, +) error { + related.UUID = uuid.New().String() // Generate UUID + related.ArticleID = articleID // Link to main article + + query := ` + INSERT INTO related_articles + (uuid, article_id, related_title, related_tag, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW())` + + _, err := p.conn.Exec( + ctx, + query, + related.UUID, + related.ArticleID, + related.RelatedTitle, + related.RelatedTag, + ) + return err +} From 5eec21a2b11419eccf3ddaf1f3b59501dba06d6d Mon Sep 17 00:00:00 2001 From: Buravit Yenjit Date: Thu, 3 Apr 2025 23:36:17 +0700 Subject: [PATCH 6/7] feat: add image_url attribute and handle method --- backend/internal/api/knowledgeHub.go | 29 +++++++++---------- backend/internal/domain/knowledgeHub.go | 13 +++++++++ .../repository/postgres_knowledgeHub.go | 23 ++++++++++----- .../00005_add_image_url_to_articles.sql | 7 +++++ 4 files changed, 48 insertions(+), 24 deletions(-) create mode 100644 backend/migrations/00005_add_image_url_to_articles.sql diff --git a/backend/internal/api/knowledgeHub.go b/backend/internal/api/knowledgeHub.go index f0eb603..8cb21f4 100644 --- a/backend/internal/api/knowledgeHub.go +++ b/backend/internal/api/knowledgeHub.go @@ -81,13 +81,14 @@ type GetKnowledgeArticleByIDOutput struct { type CreateOrUpdateKnowledgeArticleInput struct { Body struct { - UUID string `json:"uuid,omitempty"` // Optional for create, required for update + UUID string `json:"uuid,omitempty"` Title string `json:"title"` Content string `json:"content"` Author string `json:"author"` PublishDate time.Time `json:"publish_date"` ReadTime string `json:"read_time"` Categories []string `json:"categories"` + ImageURL string `json:"image_url"` } `json:"body"` } @@ -130,7 +131,7 @@ func (a *api) getAllKnowledgeArticlesHandler(ctx context.Context, input *struct{ } func (a *api) getKnowledgeArticleByIDHandler(ctx context.Context, input *struct { - UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` + UUID string `path:"uuid"` }) (*GetKnowledgeArticleByIDOutput, error) { resp := &GetKnowledgeArticleByIDOutput{} @@ -138,8 +139,7 @@ func (a *api) getKnowledgeArticleByIDHandler(ctx context.Context, input *struct return nil, huma.Error400BadRequest("UUID parameter is required") } - _, err := uuid.FromString(input.UUID) - if err != nil { + if _, err := uuid.FromString(input.UUID); err != nil { return nil, huma.Error400BadRequest("invalid UUID format") } @@ -156,7 +156,7 @@ func (a *api) getKnowledgeArticleByIDHandler(ctx context.Context, input *struct } func (a *api) getKnowledgeArticlesByCategoryHandler(ctx context.Context, input *struct { - Category string `path:"category" example:"Sustainability"` + Category string `path:"category"` }) (*GetKnowledgeArticlesOutput, error) { resp := &GetKnowledgeArticlesOutput{} @@ -190,8 +190,7 @@ func (a *api) createOrUpdateKnowledgeArticleHandler(ctx context.Context, input * } if input.Body.UUID != "" { - _, err := uuid.FromString(input.Body.UUID) - if err != nil { + if _, err := uuid.FromString(input.Body.UUID); err != nil { return nil, huma.Error400BadRequest("invalid UUID format") } } @@ -204,10 +203,10 @@ func (a *api) createOrUpdateKnowledgeArticleHandler(ctx context.Context, input * PublishDate: input.Body.PublishDate, ReadTime: input.Body.ReadTime, Categories: input.Body.Categories, + ImageURL: input.Body.ImageURL, } - err := a.knowledgeHubRepo.CreateOrUpdateArticle(ctx, article) - if err != nil { + if err := a.knowledgeHubRepo.CreateOrUpdateArticle(ctx, article); err != nil { return nil, err } @@ -216,7 +215,7 @@ func (a *api) createOrUpdateKnowledgeArticleHandler(ctx context.Context, input * } func (a *api) getArticleTableOfContentsHandler(ctx context.Context, input *struct { - UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` + UUID string `path:"uuid"` }) (*GetTableOfContentsOutput, error) { resp := &GetTableOfContentsOutput{} @@ -224,8 +223,7 @@ func (a *api) getArticleTableOfContentsHandler(ctx context.Context, input *struc return nil, huma.Error400BadRequest("UUID parameter is required") } - _, err := uuid.FromString(input.UUID) - if err != nil { + if _, err := uuid.FromString(input.UUID); err != nil { return nil, huma.Error400BadRequest("invalid UUID format") } @@ -242,7 +240,7 @@ func (a *api) getArticleTableOfContentsHandler(ctx context.Context, input *struc } func (a *api) getArticleRelatedArticlesHandler(ctx context.Context, input *struct { - UUID string `path:"uuid" example:"550e8400-e29b-41d4-a716-446655440000"` + UUID string `path:"uuid"` }) (*GetRelatedArticlesOutput, error) { resp := &GetRelatedArticlesOutput{} @@ -250,8 +248,7 @@ func (a *api) getArticleRelatedArticlesHandler(ctx context.Context, input *struc return nil, huma.Error400BadRequest("UUID parameter is required") } - _, err := uuid.FromString(input.UUID) - if err != nil { + if _, err := uuid.FromString(input.UUID); err != nil { return nil, huma.Error400BadRequest("invalid UUID format") } @@ -286,5 +283,5 @@ func (a *api) createRelatedArticleHandler( return nil, huma.Error500InternalServerError("failed to create related article") } - return nil, nil // HTTP 204 No Content + return nil, nil } diff --git a/backend/internal/domain/knowledgeHub.go b/backend/internal/domain/knowledgeHub.go index 45b4b22..04ad316 100644 --- a/backend/internal/domain/knowledgeHub.go +++ b/backend/internal/domain/knowledgeHub.go @@ -2,6 +2,8 @@ package domain import ( "context" + "fmt" + "strings" "time" validation "github.com/go-ozzo/ozzo-validation/v4" @@ -15,6 +17,7 @@ type KnowledgeArticle struct { PublishDate time.Time ReadTime string Categories []string + ImageURL string CreatedAt time.Time UpdatedAt time.Time } @@ -25,6 +28,16 @@ func (k *KnowledgeArticle) Validate() error { validation.Field(&k.Content, validation.Required), validation.Field(&k.Author, validation.Required), validation.Field(&k.PublishDate, validation.Required), + validation.Field(&k.ImageURL, + validation.By(func(value interface{}) error { + if url, ok := value.(string); ok && url != "" { + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + return fmt.Errorf("must be a valid URL starting with http:// or https://") + } + } + return nil + }), + ), ) } diff --git a/backend/internal/repository/postgres_knowledgeHub.go b/backend/internal/repository/postgres_knowledgeHub.go index 8e5018c..75925a1 100644 --- a/backend/internal/repository/postgres_knowledgeHub.go +++ b/backend/internal/repository/postgres_knowledgeHub.go @@ -34,6 +34,7 @@ func (p *postgresKnowledgeHubRepository) fetchArticles(ctx context.Context, quer &a.PublishDate, &a.ReadTime, &a.Categories, + &a.ImageURL, &a.CreatedAt, &a.UpdatedAt, ); err != nil { @@ -46,7 +47,7 @@ func (p *postgresKnowledgeHubRepository) fetchArticles(ctx context.Context, quer func (p *postgresKnowledgeHubRepository) GetArticleByID(ctx context.Context, uuid string) (domain.KnowledgeArticle, error) { query := ` - SELECT uuid, title, content, author, publish_date, read_time, categories, created_at, updated_at + SELECT uuid, title, content, author, publish_date, read_time, categories, image_url, created_at, updated_at FROM knowledge_articles WHERE uuid = $1` @@ -62,7 +63,7 @@ func (p *postgresKnowledgeHubRepository) GetArticleByID(ctx context.Context, uui func (p *postgresKnowledgeHubRepository) GetArticlesByCategory(ctx context.Context, category string) ([]domain.KnowledgeArticle, error) { query := ` - SELECT uuid, title, content, author, publish_date, read_time, categories, created_at, updated_at + SELECT uuid, title, content, author, publish_date, read_time, categories, image_url, created_at, updated_at FROM knowledge_articles WHERE $1 = ANY(categories)` @@ -71,20 +72,24 @@ func (p *postgresKnowledgeHubRepository) GetArticlesByCategory(ctx context.Conte func (p *postgresKnowledgeHubRepository) GetAllArticles(ctx context.Context) ([]domain.KnowledgeArticle, error) { query := ` - SELECT uuid, title, content, author, publish_date, read_time, categories, created_at, updated_at + SELECT uuid, title, content, author, publish_date, read_time, categories, image_url, created_at, updated_at FROM knowledge_articles` return p.fetchArticles(ctx, query) } -func (p *postgresKnowledgeHubRepository) CreateOrUpdateArticle(ctx context.Context, article *domain.KnowledgeArticle) error { +func (p *postgresKnowledgeHubRepository) CreateOrUpdateArticle( + ctx context.Context, + article *domain.KnowledgeArticle, +) error { if strings.TrimSpace(article.UUID) == "" { article.UUID = uuid.New().String() } query := ` - INSERT INTO knowledge_articles (uuid, title, content, author, publish_date, read_time, categories, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + INSERT INTO knowledge_articles + (uuid, title, content, author, publish_date, read_time, categories, image_url, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW()) ON CONFLICT (uuid) DO UPDATE SET title = EXCLUDED.title, content = EXCLUDED.content, @@ -92,6 +97,7 @@ func (p *postgresKnowledgeHubRepository) CreateOrUpdateArticle(ctx context.Conte publish_date = EXCLUDED.publish_date, read_time = EXCLUDED.read_time, categories = EXCLUDED.categories, + image_url = EXCLUDED.image_url, updated_at = NOW() RETURNING uuid, created_at, updated_at` @@ -105,6 +111,7 @@ func (p *postgresKnowledgeHubRepository) CreateOrUpdateArticle(ctx context.Conte article.PublishDate, article.ReadTime, article.Categories, + article.ImageURL, ).Scan(&article.UUID, &article.CreatedAt, &article.UpdatedAt) } @@ -188,8 +195,8 @@ func (p *postgresKnowledgeHubRepository) CreateRelatedArticle( articleID string, related *domain.RelatedArticle, ) error { - related.UUID = uuid.New().String() // Generate UUID - related.ArticleID = articleID // Link to main article + related.UUID = uuid.New().String() + related.ArticleID = articleID query := ` INSERT INTO related_articles diff --git a/backend/migrations/00005_add_image_url_to_articles.sql b/backend/migrations/00005_add_image_url_to_articles.sql new file mode 100644 index 0000000..2361add --- /dev/null +++ b/backend/migrations/00005_add_image_url_to_articles.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE knowledge_articles + ADD COLUMN image_url TEXT; + +-- +goose Down +ALTER TABLE knowledge_articles +DROP COLUMN image_url; \ No newline at end of file From 1dee8e7f97b34482bcd66d1b034ec25ab676154c Mon Sep 17 00:00:00 2001 From: Buravit Yenjit Date: Fri, 4 Apr 2025 01:19:14 +0700 Subject: [PATCH 7/7] feat: Update TableOfContent Handler --- backend/internal/api/knowledgeHub.go | 85 +++++++++++++++++++ backend/internal/domain/knowledgeHub.go | 18 ++-- backend/internal/repository/connection.go | 1 + .../repository/postgres_knowledgeHub.go | 50 +++++++++++ backend/internal/service/toc_generator.go | 37 ++++++++ .../00004_create_knowledge_hub_tables.sql | 14 +-- 6 files changed, 192 insertions(+), 13 deletions(-) create mode 100644 backend/internal/service/toc_generator.go diff --git a/backend/internal/api/knowledgeHub.go b/backend/internal/api/knowledgeHub.go index 8cb21f4..3a7057a 100644 --- a/backend/internal/api/knowledgeHub.go +++ b/backend/internal/api/knowledgeHub.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strings" "time" "github.com/danielgtaylor/huma/v2" @@ -65,6 +66,13 @@ func (a *api) registerKnowledgeHubRoutes(_ chi.Router, api huma.API) { Path: prefix + "/{uuid}/related", Tags: tags, }, a.createRelatedArticleHandler) + + huma.Register(api, huma.Operation{ + OperationID: "generateTableOfContents", + Method: http.MethodPost, + Path: prefix + "/{uuid}/generate-toc", + Tags: tags, + }, a.generateTOCHandler) } type GetKnowledgeArticlesOutput struct { @@ -118,6 +126,16 @@ type CreateRelatedArticleInput struct { } `json:"body"` } +type GenerateTOCInput struct { + UUID string `path:"uuid"` +} + +type GenerateTOCOutput struct { + Body struct { + TableOfContents []domain.TableOfContent `json:"table_of_contents"` + } `json:"body"` +} + func (a *api) getAllKnowledgeArticlesHandler(ctx context.Context, input *struct{}) (*GetKnowledgeArticlesOutput, error) { resp := &GetKnowledgeArticlesOutput{} @@ -285,3 +303,70 @@ func (a *api) createRelatedArticleHandler( return nil, nil } + +func generateTOCFromContent(content string) []domain.TableOfContent { + var toc []domain.TableOfContent + lines := strings.Split(content, "\n") + order := 0 + + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "# ") { + order++ + toc = append(toc, domain.TableOfContent{ + Title: strings.TrimPrefix(line, "# "), + Level: 1, + Order: order, + }) + } else if strings.HasPrefix(line, "## ") { + order++ + toc = append(toc, domain.TableOfContent{ + Title: strings.TrimPrefix(line, "## "), + Level: 2, + Order: order, + }) + } else if strings.HasPrefix(line, "### ") { + order++ + toc = append(toc, domain.TableOfContent{ + Title: strings.TrimPrefix(line, "### "), + Level: 3, + Order: order, + }) + } + // Add more levels if needed + } + + return toc +} + +func (a *api) generateTOCHandler( + ctx context.Context, + input *GenerateTOCInput, +) (*GenerateTOCOutput, error) { + resp := &GenerateTOCOutput{} + + // Validate UUID format + if _, err := uuid.FromString(input.UUID); err != nil { + return nil, huma.Error400BadRequest("invalid UUID format") + } + + // Get the article + article, err := a.knowledgeHubRepo.GetArticleByID(ctx, input.UUID) + if err != nil { + if errors.Is(err, domain.ErrNotFound) { + return nil, huma.Error404NotFound("article not found") + } + return nil, err + } + + // Generate TOC from content + tocItems := generateTOCFromContent(article.Content) + + // Save to database + if err := a.knowledgeHubRepo.CreateTableOfContents(ctx, input.UUID, tocItems); err != nil { + return nil, huma.Error500InternalServerError("failed to save table of contents") + } + + resp.Body.TableOfContents = tocItems + return resp, nil +} diff --git a/backend/internal/domain/knowledgeHub.go b/backend/internal/domain/knowledgeHub.go index 04ad316..9cdf08b 100644 --- a/backend/internal/domain/knowledgeHub.go +++ b/backend/internal/domain/knowledgeHub.go @@ -42,12 +42,13 @@ func (k *KnowledgeArticle) Validate() error { } type TableOfContent struct { - UUID string - ArticleID string - Title string - Order int - CreatedAt time.Time - UpdatedAt time.Time + UUID string `json:"uuid"` + ArticleID string `json:"article_id"` + Title string `json:"title"` + Level int `json:"level"` + Order int `json:"order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type RelatedArticle struct { @@ -60,13 +61,18 @@ type RelatedArticle struct { } type KnowledgeHubRepository interface { + // Article methods GetArticleByID(context.Context, string) (KnowledgeArticle, error) GetArticlesByCategory(ctx context.Context, category string) ([]KnowledgeArticle, error) GetAllArticles(ctx context.Context) ([]KnowledgeArticle, error) CreateOrUpdateArticle(context.Context, *KnowledgeArticle) error DeleteArticle(context.Context, string) error + // Table of Contents methods GetTableOfContents(ctx context.Context, articleID string) ([]TableOfContent, error) + CreateTableOfContents(ctx context.Context, articleID string, items []TableOfContent) error + + // Related Articles methods GetRelatedArticles(ctx context.Context, articleID string) ([]RelatedArticle, error) CreateRelatedArticle(ctx context.Context, articleID string, related *RelatedArticle) error } diff --git a/backend/internal/repository/connection.go b/backend/internal/repository/connection.go index 63d6305..0d0c99e 100644 --- a/backend/internal/repository/connection.go +++ b/backend/internal/repository/connection.go @@ -11,4 +11,5 @@ type Connection interface { Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) Query(context.Context, string, ...interface{}) (pgx.Rows, error) QueryRow(context.Context, string, ...interface{}) pgx.Row + BeginTx(ctx context.Context, txOptions pgx.TxOptions) (pgx.Tx, error) } diff --git a/backend/internal/repository/postgres_knowledgeHub.go b/backend/internal/repository/postgres_knowledgeHub.go index 75925a1..53b983d 100644 --- a/backend/internal/repository/postgres_knowledgeHub.go +++ b/backend/internal/repository/postgres_knowledgeHub.go @@ -2,6 +2,8 @@ package repository import ( "context" + "fmt" + "github.com/jackc/pgx/v5" "strings" "github.com/forfarm/backend/internal/domain" @@ -213,3 +215,51 @@ func (p *postgresKnowledgeHubRepository) CreateRelatedArticle( ) return err } + +func (p *postgresKnowledgeHubRepository) CreateTableOfContents( + ctx context.Context, + articleID string, + items []domain.TableOfContent, +) error { + // Begin transaction + tx, err := p.conn.BeginTx(ctx, pgx.TxOptions{}) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer func() { + if err != nil { + tx.Rollback(ctx) + } + }() + + // First delete existing TOC items for this article + _, err = tx.Exec(ctx, `DELETE FROM table_of_contents WHERE article_id = $1`, articleID) + if err != nil { + return fmt.Errorf("failed to delete existing TOC items: %w", err) + } + + // Insert new TOC items + for _, item := range items { + item.UUID = uuid.New().String() + _, err = tx.Exec(ctx, ` + INSERT INTO table_of_contents + (uuid, article_id, title, level, "order", created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW())`, + item.UUID, + articleID, + item.Title, + item.Level, + item.Order, + ) + if err != nil { + return fmt.Errorf("failed to insert TOC item: %w", err) + } + } + + // Commit transaction + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("failed to commit transaction: %w", err) + } + + return nil +} diff --git a/backend/internal/service/toc_generator.go b/backend/internal/service/toc_generator.go new file mode 100644 index 0000000..7f85f6a --- /dev/null +++ b/backend/internal/service/toc_generator.go @@ -0,0 +1,37 @@ +package service + +import ( + "github.com/forfarm/backend/internal/domain" + "regexp" + "strings" +) + +type TOCGenerator struct{} + +func NewTOCGenerator() *TOCGenerator { + return &TOCGenerator{} +} + +func (g *TOCGenerator) GenerateFromContent(content string) []domain.TableOfContent { + var toc []domain.TableOfContent + lines := strings.Split(content, "\n") + order := 0 + + headerRegex := regexp.MustCompile(`^(#{1,6})\s+(.*)$`) + + for _, line := range lines { + if matches := headerRegex.FindStringSubmatch(line); matches != nil { + order++ + level := len(matches[1]) // Number of # indicates level + title := matches[2] + + toc = append(toc, domain.TableOfContent{ + Title: title, + Level: level, + Order: order, + }) + } + } + + return toc +} diff --git a/backend/migrations/00004_create_knowledge_hub_tables.sql b/backend/migrations/00004_create_knowledge_hub_tables.sql index 4d8b722..01598de 100644 --- a/backend/migrations/00004_create_knowledge_hub_tables.sql +++ b/backend/migrations/00004_create_knowledge_hub_tables.sql @@ -13,13 +13,13 @@ CREATE TABLE knowledge_articles ( ); CREATE TABLE table_of_contents ( - uuid UUID PRIMARY KEY DEFAULT gen_random_uuid(), - article_id UUID NOT NULL, - title TEXT NOT NULL, - "order" INT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - CONSTRAINT fk_toc_article FOREIGN KEY (article_id) REFERENCES knowledge_articles(uuid) ON DELETE CASCADE + uuid UUID PRIMARY KEY, + article_id UUID NOT NULL REFERENCES knowledge_articles(uuid), + title TEXT NOT NULL, + level INT NOT NULL, + "order" INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE related_articles (