Merge pull request #24 from ForFarmTeam/feature-knowledge-hub

Feature knowledge hub
This commit is contained in:
Sirin Puenggun 2025-04-04 15:34:35 +07:00 committed by GitHub
commit e2d7b95631
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 805 additions and 0 deletions

View File

@ -28,6 +28,7 @@ type api struct {
plantRepo domain.PlantRepository
inventoryRepo domain.InventoryRepository
harvestRepo domain.HarvestRepository
knowledgeHubRepo domain.KnowledgeHubRepository
}
func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api {
@ -38,6 +39,7 @@ 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)
inventoryRepository := repository.NewPostgresInventory(pool)
harvestRepository := repository.NewPostgresHarvest(pool)
@ -51,6 +53,7 @@ func NewAPI(ctx context.Context, logger *slog.Logger, pool *pgxpool.Pool) *api {
plantRepo: plantRepository,
inventoryRepo: inventoryRepository,
harvestRepo: harvestRepository,
knowledgeHubRepo: knowledgeHubRepository,
}
}
@ -83,6 +86,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) {

View File

@ -0,0 +1,372 @@
package api
import (
"context"
"errors"
"net/http"
"strings"
"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)
huma.Register(api, huma.Operation{
OperationID: "createRelatedArticle",
Method: http.MethodPost,
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 {
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"`
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"`
}
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"`
}
type CreateRelatedArticleInput struct {
UUID string `path:"uuid"`
Body struct {
RelatedTitle string `json:"related_title"`
RelatedTag string `json:"related_tag"`
} `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{}
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"`
}) (*GetKnowledgeArticleByIDOutput, error) {
resp := &GetKnowledgeArticleByIDOutput{}
if input.UUID == "" {
return nil, huma.Error400BadRequest("UUID parameter is required")
}
if _, err := uuid.FromString(input.UUID); 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"`
}) (*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 != "" {
if _, err := uuid.FromString(input.Body.UUID); 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,
ImageURL: input.Body.ImageURL,
}
if err := a.knowledgeHubRepo.CreateOrUpdateArticle(ctx, article); 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"`
}) (*GetTableOfContentsOutput, error) {
resp := &GetTableOfContentsOutput{}
if input.UUID == "" {
return nil, huma.Error400BadRequest("UUID parameter is required")
}
if _, err := uuid.FromString(input.UUID); 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"`
}) (*GetRelatedArticlesOutput, error) {
resp := &GetRelatedArticlesOutput{}
if input.UUID == "" {
return nil, huma.Error400BadRequest("UUID parameter is required")
}
if _, err := uuid.FromString(input.UUID); 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
}
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
}
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
}

View File

@ -0,0 +1,78 @@
package domain
import (
"context"
"fmt"
"strings"
"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
ImageURL 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),
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
}),
),
)
}
type TableOfContent struct {
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 {
UUID string
ArticleID string
RelatedTitle string
RelatedTag string
CreatedAt time.Time
UpdatedAt time.Time
}
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
}

View File

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

View File

@ -0,0 +1,265 @@
package repository
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"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.ImageURL,
&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, image_url, 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, image_url, 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, 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 {
if strings.TrimSpace(article.UUID) == "" {
article.UUID = uuid.New().String()
}
query := `
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,
author = EXCLUDED.author,
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`
return p.conn.QueryRow(
ctx,
query,
article.UUID,
article.Title,
article.Content,
article.Author,
article.PublishDate,
article.ReadTime,
article.Categories,
article.ImageURL,
).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)
}
func (p *postgresKnowledgeHubRepository) CreateRelatedArticle(
ctx context.Context,
articleID string,
related *domain.RelatedArticle,
) error {
related.UUID = uuid.New().String()
related.ArticleID = articleID
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
}
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
}

View File

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

View File

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

View File

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