diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 6c7d997..ee3fd15 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -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) { diff --git a/backend/internal/api/knowledgeHub.go b/backend/internal/api/knowledgeHub.go new file mode 100644 index 0000000..3a7057a --- /dev/null +++ b/backend/internal/api/knowledgeHub.go @@ -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 +} diff --git a/backend/internal/domain/knowledgeHub.go b/backend/internal/domain/knowledgeHub.go new file mode 100644 index 0000000..9cdf08b --- /dev/null +++ b/backend/internal/domain/knowledgeHub.go @@ -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 +} 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 new file mode 100644 index 0000000..53b983d --- /dev/null +++ b/backend/internal/repository/postgres_knowledgeHub.go @@ -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 +} 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 new file mode 100644 index 0000000..01598de --- /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, + 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 \ No newline at end of file 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