diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go index 591eb46..e092343 100644 --- a/backend/internal/api/api.go +++ b/backend/internal/api/api.go @@ -30,13 +30,14 @@ type api struct { httpClient *http.Client eventPublisher domain.EventPublisher - userRepo domain.UserRepository - cropRepo domain.CroplandRepository - farmRepo domain.FarmRepository - plantRepo domain.PlantRepository - inventoryRepo domain.InventoryRepository - harvestRepo domain.HarvestRepository - analyticsRepo domain.AnalyticsRepository + userRepo domain.UserRepository + cropRepo domain.CroplandRepository + farmRepo domain.FarmRepository + plantRepo domain.PlantRepository + inventoryRepo domain.InventoryRepository + harvestRepo domain.HarvestRepository + analyticsRepo domain.AnalyticsRepository + knowledgeHubRepo domain.KnowledgeHubRepository weatherFetcher domain.WeatherFetcher @@ -61,8 +62,9 @@ func NewAPI( client := &http.Client{} userRepository := repository.NewPostgresUser(pool) - harvestRepository := repository.NewPostgresHarvest(pool) plantRepository := repository.NewPostgresPlant(pool) + knowledgeHubRepository := repository.NewPostgresKnowledgeHub(pool) + harvestRepository := repository.NewPostgresHarvest(pool) owmFetcher := weather.NewOpenWeatherMapFetcher(config.OPENWEATHER_API_KEY, client, logger) cacheTTL, err := time.ParseDuration(config.OPENWEATHER_CACHE_TTL) @@ -87,15 +89,15 @@ func NewAPI( httpClient: client, eventPublisher: eventPublisher, - userRepo: userRepository, - cropRepo: croplandRepo, - farmRepo: farmRepo, - plantRepo: plantRepository, - inventoryRepo: inventoryRepo, - harvestRepo: harvestRepository, - analyticsRepo: analyticsRepo, - - weatherFetcher: cachedWeatherFetcher, + userRepo: userRepository, + cropRepo: croplandRepo, + farmRepo: farmRepo, + plantRepo: plantRepository, + inventoryRepo: inventoryRepo, + harvestRepo: harvestRepository, + analyticsRepo: analyticsRepo, + knowledgeHubRepo: knowledgeHubRepository, + weatherFetcher: cachedWeatherFetcher, chatService: chatService, } @@ -138,6 +140,7 @@ func (a *api) Routes() *chi.Mux { a.registerAuthRoutes(r, api) a.registerCropRoutes(r, api) a.registerPlantRoutes(r, api) + a.registerKnowledgeHubRoutes(r, api) a.registerOauthRoutes(r, api) a.registerChatRoutes(r, api) a.registerInventoryRoutes(r, api) diff --git a/backend/internal/api/inventory.go b/backend/internal/api/inventory.go index 17e7eca..04ca1cf 100644 --- a/backend/internal/api/inventory.go +++ b/backend/internal/api/inventory.go @@ -77,10 +77,10 @@ type InventoryItemResponse struct { Category InventoryCategory `json:"category"` Quantity float64 `json:"quantity"` Unit HarvestUnit `json:"unit"` - DateAdded time.Time `json:"date_added"` + DateAdded time.Time `json:"dateAdded"` Status InventoryStatus `json:"status"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `json:"updated_at,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` } type InventoryStatus struct { @@ -100,14 +100,13 @@ type HarvestUnit struct { type CreateInventoryItemInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` Body struct { Name string `json:"name" required:"true"` - CategoryID int `json:"category_id" required:"true"` + CategoryID int `json:"categoryId" required:"true"` Quantity float64 `json:"quantity" required:"true"` - UnitID int `json:"unit_id" required:"true"` - DateAdded time.Time `json:"date_added" required:"true"` - StatusID int `json:"status_id" required:"true"` + UnitID int `json:"unitId" required:"true"` + DateAdded time.Time `json:"dateAdded" required:"true"` + StatusID int `json:"statusId" required:"true"` } } @@ -119,15 +118,14 @@ type CreateInventoryItemOutput struct { type UpdateInventoryItemInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` ID string `path:"id"` Body struct { Name string `json:"name"` - CategoryID int `json:"category_id"` + CategoryID int `json:"categoryId"` Quantity float64 `json:"quantity"` - UnitID int `json:"unit_id"` - DateAdded time.Time `json:"date_added"` - StatusID int `json:"status_id"` + UnitID int `json:"unitId"` + DateAdded time.Time `json:"dateAdded"` + StatusID int `json:"statusId"` } } @@ -137,14 +135,13 @@ type UpdateInventoryItemOutput struct { type GetInventoryItemsInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` - CategoryID int `query:"category_id"` - StatusID int `query:"status_id"` - StartDate time.Time `query:"start_date" format:"date-time"` - EndDate time.Time `query:"end_date" format:"date-time"` + CategoryID int `query:"categoryId"` + StatusID int `query:"statusId"` + StartDate time.Time `query:"startDate" format:"date-time"` + EndDate time.Time `query:"endDate" format:"date-time"` SearchQuery string `query:"search"` - SortBy string `query:"sort_by" enum:"name,quantity,date_added,created_at"` - SortOrder string `query:"sort_order" enum:"asc,desc" default:"desc"` + SortBy string `query:"sortBy" enum:"name,quantity,dateAdded,createdAt"` + SortOrder string `query:"sortOrder" enum:"asc,desc" default:"desc"` } type GetInventoryItemsOutput struct { @@ -153,7 +150,7 @@ type GetInventoryItemsOutput struct { type GetInventoryItemInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` + UserID string `header:"userId" required:"true" example:"user-uuid"` ID string `path:"id"` } @@ -163,7 +160,6 @@ type GetInventoryItemOutput struct { type DeleteInventoryItemInput struct { Header string `header:"Authorization" required:"true" example:"Bearer token"` - UserID string `header:"user_id" required:"true" example:"user-uuid"` ID string `path:"id"` } @@ -186,8 +182,9 @@ type GetHarvestUnitsOutput struct { } func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInventoryItemInput) (*CreateInventoryItemOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) item := &domain.InventoryItem{ - UserID: input.UserID, + UserID: userID, Name: input.Body.Name, CategoryID: input.Body.CategoryID, Quantity: input.Body.Quantity, @@ -200,7 +197,7 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven return nil, huma.Error422UnprocessableEntity(err.Error()) } - err := a.inventoryRepo.CreateOrUpdate(ctx, item) + err = a.inventoryRepo.CreateOrUpdate(ctx, item) if err != nil { return nil, err } @@ -211,8 +208,9 @@ func (a *api) createInventoryItemHandler(ctx context.Context, input *CreateInven } func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInventoryItemsInput) (*GetInventoryItemsOutput, error) { + userID, err := a.getUserIDFromHeader(input.Header) filter := domain.InventoryFilter{ - UserID: input.UserID, + UserID: userID, CategoryID: input.CategoryID, StatusID: input.StatusID, StartDate: input.StartDate, @@ -225,7 +223,7 @@ func (a *api) getInventoryItemsByUserHandler(ctx context.Context, input *GetInve Direction: input.SortOrder, } - items, err := a.inventoryRepo.GetByUserID(ctx, input.UserID, filter, sort) + items, err := a.inventoryRepo.GetByUserID(ctx, userID, filter, sort) if err != nil { return nil, err } @@ -286,7 +284,8 @@ func (a *api) getInventoryItemHandler(ctx context.Context, input *GetInventoryIt } func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInventoryItemInput) (*UpdateInventoryItemOutput, error) { - item, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID) + userID, err := a.getUserIDFromHeader(input.Header) + item, err := a.inventoryRepo.GetByID(ctx, input.ID, userID) if err != nil { return nil, err } @@ -319,7 +318,7 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven return nil, err } - updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID, input.UserID) + updatedItem, err := a.inventoryRepo.GetByID(ctx, input.ID, userID) if err != nil { return nil, err } @@ -347,7 +346,8 @@ func (a *api) updateInventoryItemHandler(ctx context.Context, input *UpdateInven } func (a *api) deleteInventoryItemHandler(ctx context.Context, input *DeleteInventoryItemInput) (*DeleteInventoryItemOutput, error) { - err := a.inventoryRepo.Delete(ctx, input.ID, input.UserID) + userID, err := a.getUserIDFromHeader(input.Header) + err = a.inventoryRepo.Delete(ctx, input.ID, userID) if err != nil { return nil, err } 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 diff --git a/frontend/api/harvest.ts b/frontend/api/harvest.ts new file mode 100644 index 0000000..4961212 --- /dev/null +++ b/frontend/api/harvest.ts @@ -0,0 +1,12 @@ +import axiosInstance from "./config"; +import type { HarvestUnits } from "@/types"; + +export async function fetchHarvestUnits(): Promise { + try { + const response = await axiosInstance.get("/harvest/units"); + return response.data; + } catch (error) { + console.error("Error fetching inventory status:", error); + return []; + } +} diff --git a/frontend/api/inventory.ts b/frontend/api/inventory.ts index 1c59e20..6b6b5c0 100644 --- a/frontend/api/inventory.ts +++ b/frontend/api/inventory.ts @@ -1,20 +1,23 @@ import axiosInstance from "./config"; import type { InventoryItem, + InventoryStatus, + InventoryItemCategory, CreateInventoryItemInput, - InventoryItemStatus, + EditInventoryItemInput, } from "@/types"; +import { AxiosError } from "axios"; /** * Simulates an API call to fetch inventory items. * Waits for a simulated delay and then attempts an axios GET request. * If the request fails, returns fallback dummy data. - * - * + * + * */ -export async function fetchInventoryStatus(): Promise { +export async function fetchInventoryStatus(): Promise { try { - const response = await axiosInstance.get( + const response = await axiosInstance.get( "/inventory/status" ); return response.data; @@ -23,96 +26,136 @@ export async function fetchInventoryStatus(): Promise { return []; } } +export async function fetchInventoryCategory(): Promise< + InventoryItemCategory[] +> { + try { + const response = await axiosInstance.get( + "/inventory/category" + ); + return response.data; + } catch (error) { + console.error("Error fetching inventory status:", error); + return []; + } +} export async function fetchInventoryItems(): Promise { try { - const response = await axiosInstance.get("/api/inventory"); + const response = await axiosInstance.get("/inventory"); return response.data; } catch (error) { - // Fallback dummy data - return [ - { - id: 1, - name: "Tomato Seeds", - category: "Seeds", - type: "Plantation", - quantity: 500, - unit: "packets", - lastUpdated: "2023-03-01", - status: "In Stock", - }, - { - id: 2, - name: "NPK Fertilizer", - category: "Fertilizer", - type: "Fertilizer", - quantity: 200, - unit: "kg", - lastUpdated: "2023-03-05", - status: "Low Stock", - }, - { - id: 3, - name: "Corn Seeds", - category: "Seeds", - type: "Plantation", - quantity: 300, - unit: "packets", - lastUpdated: "2023-03-10", - status: "In Stock", - }, - { - id: 4, - name: "Organic Compost", - category: "Fertilizer", - type: "Fertilizer", - quantity: 150, - unit: "kg", - lastUpdated: "2023-03-15", - status: "Out Of Stock", - }, - { - id: 5, - name: "Wheat Seeds", - category: "Seeds", - type: "Plantation", - quantity: 250, - unit: "packets", - lastUpdated: "2023-03-20", - status: "In Stock", - }, - ]; + console.error("Error while fetching inventory items! " + error); + throw error; } } -/** - * Simulates creating a new inventory item. - * Uses axios POST and if unavailable, returns a simulated response. - * - * Note: The function accepts all fields except id, lastUpdated, and status. - */ export async function createInventoryItem( - item: Omit + item: Omit ): Promise { - // Simulate network delay - await new Promise((resolve) => setTimeout(resolve, 500)); try { const response = await axiosInstance.post( - "/api/inventory", + "/inventory", item ); return response.data; - } catch (error) { - // Simulate successful creation if API endpoint is not available - return { - id: Math.floor(Math.random() * 1000), - name: item.name, - category: item.category, - type: item.type, - quantity: item.quantity, - unit: item.unit, - lastUpdated: new Date().toISOString(), - status: "In Stock", - }; + } catch (error: unknown) { + // Cast error to AxiosError to safely access response properties + if (error instanceof AxiosError && error.response) { + // Log the detailed error message + console.error("Error while creating Inventory Item!"); + console.error("Response Status:", error.response.status); // e.g., 422 + console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend + console.error("Full Error Response:", error.response.data); // Entire error object (including details) + + // Throw a new error with a more specific message + throw new Error( + `Failed to create inventory item: ${ + error.response.data?.detail || error.message + }` + ); + } else { + // Handle other errors (e.g., network errors or unknown errors) + console.error( + "Error while creating Inventory Item, unknown error:", + error + ); + throw new Error( + "Failed to create inventory item: " + + (error instanceof Error ? error.message : "Unknown error") + ); + } + } +} + +export async function deleteInventoryItem(id: string) { + try { + const response = await axiosInstance.delete("/inventory/" + id); + return response.data; + } catch (error: unknown) { + // Cast error to AxiosError to safely access response properties + if (error instanceof AxiosError && error.response) { + // Log the detailed error message + console.error("Error while deleting Inventory Item!"); + console.error("Response Status:", error.response.status); // e.g., 422 + console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend + console.error("Full Error Response:", error.response.data); // Entire error object (including details) + + // Throw a new error with a more specific message + throw new Error( + `Failed to delete inventory item: ${ + error.response.data?.detail || error.message + }` + ); + } else { + // Handle other errors (e.g., network errors or unknown errors) + console.error( + "Error while deleting Inventory Item, unknown error:", + error + ); + throw new Error( + "Failed to delete inventory item: " + + (error instanceof Error ? error.message : "Unknown error") + ); + } + } +} +export async function updateInventoryItem( + id: string, + item: EditInventoryItemInput +) { + // console.log(id); + try { + const response = await axiosInstance.put( + `/inventory/${id}`, + item + ); + return response.data; + } catch (error: unknown) { + // Cast error to AxiosError to safely access response properties + if (error instanceof AxiosError && error.response) { + // Log the detailed error message + console.error("Error while deleting Inventory Item!"); + console.error("Response Status:", error.response.status); // e.g., 422 + console.error("Error Detail:", error.response.data?.detail); // Custom error message from backend + console.error("Full Error Response:", error.response.data); // Entire error object (including details) + + // Throw a new error with a more specific message + throw new Error( + `Failed to delete inventory item: ${ + error.response.data?.detail || error.message + }` + ); + } else { + // Handle other errors (e.g., network errors or unknown errors) + console.error( + "Error while deleting Inventory Item, unknown error:", + error + ); + throw new Error( + "Failed to delete inventory item: " + + (error instanceof Error ? error.message : "Unknown error") + ); + } } } diff --git a/frontend/app/(sidebar)/inventory/add-inventory-item.tsx b/frontend/app/(sidebar)/inventory/add-inventory-item.tsx index 448097c..e751ad8 100644 --- a/frontend/app/(sidebar)/inventory/add-inventory-item.tsx +++ b/frontend/app/(sidebar)/inventory/add-inventory-item.tsx @@ -4,7 +4,6 @@ import { useState } from "react"; import { CalendarIcon } from "lucide-react"; import { format } from "date-fns"; import { useMutation, useQueryClient } from "@tanstack/react-query"; - import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { @@ -18,7 +17,11 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Select, SelectContent, @@ -30,142 +33,242 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { createInventoryItem } from "@/api/inventory"; -import type { CreateInventoryItemInput } from "@/types"; +import type { + CreateInventoryItemInput, + InventoryStatus, + InventoryItemCategory, + HarvestUnits, +} from "@/types"; -export function AddInventoryItem() { +interface AddInventoryItemProps { + inventoryCategory: InventoryItemCategory[]; + inventoryStatus: InventoryStatus[]; + harvestUnits: HarvestUnits[]; +} + +export function AddInventoryItem({ + inventoryCategory, + inventoryStatus, + harvestUnits, +}: AddInventoryItemProps) { const [date, setDate] = useState(); const [open, setOpen] = useState(false); const [itemName, setItemName] = useState(""); - const [itemType, setItemType] = useState(""); const [itemCategory, setItemCategory] = useState(""); const [itemQuantity, setItemQuantity] = useState(0); const [itemUnit, setItemUnit] = useState(""); - + const [itemStatus, setItemStatus] = useState(""); + const [isSubmitted, setIsSubmitted] = useState(false); + const [successMessage, setSuccessMessage] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); const queryClient = useQueryClient(); - const mutation = useMutation({ mutationFn: (item: CreateInventoryItemInput) => createInventoryItem(item), onSuccess: () => { - // Invalidate queries to refresh inventory data. + // invalidate queries to refresh inventory data queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); - // Reset form fields and close dialog. + setItemName(""); - setItemType(""); setItemCategory(""); setItemQuantity(0); setItemUnit(""); setDate(undefined); - setOpen(false); + setIsSubmitted(true); + setSuccessMessage("Item created successfully!"); + + // reset success message after 3 seconds + setTimeout(() => { + setIsSubmitted(false); + setSuccessMessage(""); + setOpen(false); + }, 3000); + }, + onError: (error: any) => { + console.error("Error creating item: ", error); + setErrorMessage( + "There was an error creating the item. Please try again." + ); + + // reset success message after 3 seconds + setTimeout(() => { + setErrorMessage(""); + }, 3000); }, }); + const inputStates = [itemName, itemCategory, itemUnit, itemStatus, date]; + const isInputValid = inputStates.every((input) => input); const handleSave = () => { - // Basic validation (you can extend this as needed) - if (!itemName || !itemType || !itemCategory || !itemUnit) return; - mutation.mutate({ + if (!isInputValid) { + setErrorMessage( + "There was an error creating the item. Please try again." + ); + return; + } + + const newItem: CreateInventoryItemInput = { name: itemName, - type: itemType, - category: itemCategory, + categoryId: + inventoryCategory.find((item) => item.name === itemCategory)?.id || 0, quantity: itemQuantity, - unit: itemUnit, - }); + unitId: harvestUnits.find((item) => item.name === itemUnit)?.id || 0, + statusId: + inventoryStatus.find((item) => item.name === itemStatus)?.id || 0, + dateAdded: date ? date.toISOString() : new Date().toISOString(), + }; + // console.table(newItem); + mutation.mutate(newItem); }; return ( - - - - - - - Add Inventory Item - Add a new plantation or fertilizer item to your inventory. - -
-
- - setItemName(e.target.value)} /> + <> + + + + + + + Add Inventory Item + + Add a new plantation or fertilizer item to your inventory. + + +
+
+ + setItemName(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ + setItemQuantity(Number(e.target.value))} + /> +
+
+ + +
+
+ + + + + + + + + +
-
- - -
-
- - setItemCategory(e.target.value)} - /> -
-
- - setItemQuantity(Number(e.target.value))} - /> -
-
- - setItemUnit(e.target.value)} - /> -
-
- - - - - - - - - -
-
- - - - -
+ +
+ + +
+ {isSubmitted && ( +

+ {successMessage} You may close this window. +

+ )} + + {errorMessage && ( +

{errorMessage}

+ )} +
+
+
+ + + ); } diff --git a/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx b/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx index e53e37d..79679c9 100644 --- a/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx +++ b/frontend/app/(sidebar)/inventory/delete-inventory-item.tsx @@ -1,63 +1,69 @@ -"use client"; - import { useState } from "react"; -import { CalendarIcon } from "lucide-react"; -import { format } from "date-fns"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; - -import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; import { Dialog, + DialogTrigger, DialogContent, + DialogTitle, DialogDescription, DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -// import { deleteInventoryItem } from "@/api/inventory"; -// import type { DeleteInventoryItemInput } from "@/types"; +import { Button } from "@/components/ui/button"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteInventoryItem } from "@/api/inventory"; -export function DeleteInventoryItem() { - const [date, setDate] = useState(); +export function DeleteInventoryItem({ id }: { id: string }) { const [open, setOpen] = useState(false); - const [itemName, setItemName] = useState(""); - const [itemType, setItemType] = useState(""); - const [itemCategory, setItemCategory] = useState(""); - const [itemQuantity, setItemQuantity] = useState(0); - const [itemUnit, setItemUnit] = useState(""); + const queryClient = useQueryClient(); - // const queryClient = useQueryClient(); + const { mutate: deleteItem, status } = useMutation({ + mutationFn: deleteInventoryItem, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); + }, + onError: (error) => { + console.error("Failed to delete item:", error); + }, + }); const handleDelete = () => { - // handle delete item + deleteItem(id.toString()); }; return ( - +
+ {/* delete confirmation dialog */} + + + + + + Confirm Deletion + + Are you sure you want to delete this item? This action cannot be + undone. + + + + + + + +
); } diff --git a/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx b/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx index b5d2a7a..9420996 100644 --- a/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx +++ b/frontend/app/(sidebar)/inventory/edit-inventory-item.tsx @@ -1,12 +1,8 @@ "use client"; import { useState } from "react"; -import { CalendarIcon } from "lucide-react"; -import { format } from "date-fns"; import { useMutation, useQueryClient } from "@tanstack/react-query"; - import { Button } from "@/components/ui/button"; -import { Calendar } from "@/components/ui/calendar"; import { Dialog, DialogContent, @@ -18,11 +14,6 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; import { Select, SelectContent, @@ -32,65 +23,93 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { cn } from "@/lib/utils"; -// import { updateInventoryItem } from "@/api/inventory"; -// import type { UpdateInventoryItemInput } from "@/types"; - -export interface EditInventoryItemProps { - id: string; - name: string; - category: string; - status: string; - type: string; - unit: string; - quantity: number; -} +import { + InventoryStatus, + InventoryItemCategory, + HarvestUnits, + UpdateInventoryItemInput, + EditInventoryItemInput, +} from "@/types"; +import { updateInventoryItem } from "@/api/inventory"; export function EditInventoryItem({ - id, - name, - category, - status, - type, - unit, - quantity, -}: EditInventoryItemProps) { + item, + fetchedInventoryStatus, + fetchedInventoryCategory, + fetchedHarvestUnits, +}: { + item: UpdateInventoryItemInput; + fetchedInventoryStatus: InventoryStatus[]; + fetchedInventoryCategory: InventoryItemCategory[]; + fetchedHarvestUnits: HarvestUnits[]; +}) { + // console.table(item); + // console.log(item.id); const [open, setOpen] = useState(false); - const [itemName, setItemName] = useState(name); - const [itemType, setItemType] = useState(type); - const [itemCategory, setItemCategory] = useState(category); - const [itemQuantity, setItemQuantity] = useState(quantity); - const [itemUnit, setItemUnit] = useState(unit); - const [itemStatus, setItemStatus] = useState(status); + const [itemName, setItemName] = useState(item.name); + const [itemCategory, setItemCategory] = useState( + fetchedInventoryCategory.find((x) => x.id === item.categoryId)?.name + ); - // const queryClient = useQueryClient(); + const [itemQuantity, setItemQuantity] = useState(item.quantity); - // const mutation = useMutation({ - // mutationFn: (item: UpdateInventoryItemInput) => UpdateInventoryItem(item), - // onSuccess: () => { - // // Invalidate queries to refresh inventory data. - // queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); - // // Reset form fields and close dialog. - // setItemName(""); - // setItemType(""); - // setItemCategory(""); - // setItemQuantity(0); - // setItemUnit(""); - // setDate(undefined); - // setOpen(false); - // }, - // }); + const [itemUnit, setItemUnit] = useState( + fetchedHarvestUnits.find((x) => x.id === item.unitId)?.name + ); + const [itemStatus, setItemStatus] = useState( + fetchedInventoryStatus.find((x) => x.id === item.statusId)?.name + ); + const [error, setError] = useState(null); + + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: (x: EditInventoryItemInput) => updateInventoryItem(item.id, x), + onSuccess: () => { + // invalidate queries to refresh inventory data. + queryClient.invalidateQueries({ queryKey: ["inventoryItems"] }); + // reset form fields and close dialog. + setItemName(""); + setItemCategory(""); + setItemQuantity(0); + setItemUnit(""); + setOpen(false); + setItemStatus(""); + }, + }); + + // send edit request const handleEdit = () => { - // // Basic validation (you can extend this as needed) - // if (!itemName || !itemType || !itemCategory || !itemUnit) return; - // mutation.mutate({ - // name: itemName, - // type: itemType, - // category: itemCategory, - // quantity: itemQuantity, - // unit: itemUnit, - // }); + if (!itemName || !itemCategory || !itemUnit) { + setError("All fields are required. Please fill in missing details."); + return; + } + + const category = fetchedInventoryCategory.find( + (c) => c.name === itemCategory + )?.id; + const unit = fetchedHarvestUnits.find((u) => u.name === itemUnit)?.id; + const status = fetchedInventoryStatus.find( + (s) => s.name === itemStatus + )?.id; + + if (!category || !unit || !status) { + setError( + "Invalid category, unit, or status. Please select a valid option." + ); + return; + } + // console.log("Mutate called"); + // console.log(item.id); + mutation.mutate({ + name: itemName, + categoryId: category, + quantity: itemQuantity ?? 0, + unitId: unit, + statusId: status, + dateAdded: new Date().toISOString(), + }); }; return ( @@ -119,17 +138,20 @@ export function EditInventoryItem({
- - Type - Plantation - Fertilizer + Category + {fetchedInventoryCategory.map((categoryItem, _) => ( + + {categoryItem.name} + + ))} @@ -138,35 +160,22 @@ export function EditInventoryItem({ - Status - In Stock - Low Stock - Out Of Stock + {fetchedInventoryStatus.map((statusItem, _) => ( + + {statusItem.name} + + ))}
-
- - setItemCategory(e.target.value)} - /> -
-
+ {error &&

{error}

}
diff --git a/frontend/app/(sidebar)/inventory/page.tsx b/frontend/app/(sidebar)/inventory/page.tsx index 6080f18..74bf472 100644 --- a/frontend/app/(sidebar)/inventory/page.tsx +++ b/frontend/app/(sidebar)/inventory/page.tsx @@ -22,22 +22,27 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { TriangleAlertIcon } from "lucide-react"; import { Pagination, PaginationContent, PaginationItem, } from "@/components/ui/pagination"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Search } from "lucide-react"; import { FaSort, FaSortUp, FaSortDown } from "react-icons/fa"; +import { fetchHarvestUnits } from "@/api/harvest"; import { Badge } from "@/components/ui/badge"; -import { fetchInventoryItems, fetchInventoryStatus } from "@/api/inventory"; -import { AddInventoryItem } from "./add-inventory-item"; import { - EditInventoryItem, - EditInventoryItemProps, -} from "./edit-inventory-item"; + fetchInventoryItems, + fetchInventoryStatus, + fetchInventoryCategory, +} from "@/api/inventory"; +import { AddInventoryItem } from "./add-inventory-item"; +import { EditInventoryItem } from "./edit-inventory-item"; import { DeleteInventoryItem } from "./delete-inventory-item"; +import { InventoryItem } from "@/types"; export default function InventoryPage() { const [sorting, setSorting] = useState([]); @@ -45,7 +50,8 @@ export default function InventoryPage() { pageIndex: 0, pageSize: 10, }); - + ////////////////////////////// + // query the necessary data for edit and etc. const { data: inventoryItems = [], isLoading: isItemLoading, @@ -55,6 +61,8 @@ export default function InventoryPage() { queryFn: fetchInventoryItems, staleTime: 60 * 1000, }); + // console.table(inventoryItems); + // console.log(inventoryItems); const { data: inventoryStatus = [], @@ -65,38 +73,98 @@ export default function InventoryPage() { queryFn: fetchInventoryStatus, staleTime: 60 * 1000, }); + + // console.log(inventoryStatus); + const { + data: inventoryCategory = [], + isLoading: isLoadingCategory, + isError: isErrorCategory, + } = useQuery({ + queryKey: ["inventoryCategory"], + queryFn: fetchInventoryCategory, + staleTime: 60 * 1000, + }); + const { + data: harvestUnits = [], + isLoading: isLoadingHarvestUnits, + isError: isErrorHarvestUnits, + } = useQuery({ + queryKey: ["harvestUnits"], + queryFn: fetchHarvestUnits, + staleTime: 60 * 1000, + }); + ////////////////////////////// // console.table(inventoryItems); - console.table(inventoryStatus); + // console.table(inventoryStatus); + // console.table(harvestUnits); + const [searchTerm, setSearchTerm] = useState(""); const filteredItems = useMemo(() => { return inventoryItems .map((item) => ({ ...item, - id: String(item.id), // Convert `id` to string here + status: { id: item.status.id, name: item.status.name }, + category: { id: item.category.id, name: item.category.name }, + unit: { id: item.unit.id, name: item.unit.name }, + fetchedInventoryStatus: inventoryStatus, + fetchedInventoryCategory: inventoryCategory, + fetchedHarvestUnits: harvestUnits, + lastUpdated: new Date(item.updatedAt).toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: true, + }), })) .filter((item) => item.name.toLowerCase().includes(searchTerm.toLowerCase()) ); }, [inventoryItems, searchTerm]); + // prepare columns for table + const columns = [ { accessorKey: "name", header: "Name" }, - { accessorKey: "category", header: "Category" }, - { accessorKey: "quantity", header: "Quantity" }, - { accessorKey: "unit", header: "Unit" }, - { accessorKey: "lastUpdated", header: "Last Updated" }, + { + accessorKey: "category", + header: "Category", + cell: ({ row }: { row: { original: InventoryItem } }) => + row.original.category.name, + }, + { + accessorKey: "quantity", + header: "Quantity", + }, + { + accessorKey: "unit", + header: "Unit", + cell: ({ row }: { row: { original: InventoryItem } }) => + row.original.unit.name, + }, + { + accessorKey: "lastUpdated", + header: "Last Updated", + }, { accessorKey: "status", header: "Status", - cell: (info: { getValue: () => string }) => { - const status = info.getValue(); + cell: ({ row }: { row: { original: InventoryItem } }) => { + const status = row.original.status.name; - let statusClass = ""; // default status class + let statusClass = ""; - if (status === "Low Stock") { - statusClass = "bg-yellow-300"; // yellow for low stock - } else if (status === "Out Of Stock") { - statusClass = "bg-red-500 text-white"; // red for out of stock + if (status === "In Stock") { + statusClass = "bg-green-500 hover:bg-green-600 text-white"; + } else if (status === "Low Stock") { + statusClass = "bg-yellow-300 hover:bg-yellow-400"; + } else if (status === "Out of Stock") { + statusClass = "bg-red-500 hover:bg-red-600 text-white"; + } else if (status === "Expired") { + statusClass = "bg-gray-500 hover:bg-gray-600 text-white"; + } else if (status === "Reserved") { + statusClass = "bg-blue-500 hover:bg-blue-600 text-white"; } return ( @@ -109,15 +177,30 @@ export default function InventoryPage() { { accessorKey: "edit", header: "Edit", - cell: ({ row }: { row: { original: EditInventoryItemProps } }) => ( - + cell: ({ row }: { row: { original: InventoryItem } }) => ( + ), enableSorting: false, }, { accessorKey: "delete", header: "Delete", - cell: () => , + cell: ({ row }: { row: { original: InventoryItem } }) => ( + + ), enableSorting: false, }, ]; @@ -132,20 +215,61 @@ export default function InventoryPage() { onSortingChange: setSorting, onPaginationChange: setPagination, }); + const loadingStates = [ + isItemLoading, + isLoadingStatus, + isLoadingCategory, + isLoadingHarvestUnits, + ]; + const errorStates = [ + isItemError, + isErrorStatus, + isErrorCategory, + isErrorHarvestUnits, + ]; - if (isItemLoading || isLoadingStatus) + const isLoading = loadingStates.some((loading) => loading); + const isError = errorStates.some((error) => error); + if (isLoading) return (
Loading...
); - if (isItemError || isErrorStatus) + + if (isError) return (
Error loading inventory data.
); + if (inventoryItems.length === 0) { + return ( +
+ +
+ + No Inventory Data + +
+ You currently have no inventory items. Add a new item to get + started! +
+
+ +
+
+
+
+
+ ); + } + return (
@@ -159,7 +283,11 @@ export default function InventoryPage() { value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} /> - +
diff --git a/frontend/app/auth/signup/page.tsx b/frontend/app/auth/signup/page.tsx index ed0cb02..24b28a4 100644 --- a/frontend/app/auth/signup/page.tsx +++ b/frontend/app/auth/signup/page.tsx @@ -14,9 +14,18 @@ import type { z } from "zod"; import { useRouter } from "next/navigation"; import { registerUser } from "@/api/authentication"; import { SessionContext } from "@/context/SessionContext"; -import { Eye, EyeOff, Leaf, ArrowRight, AlertCircle, Loader2, Check } from "lucide-react"; +import { + Eye, + EyeOff, + Leaf, + ArrowRight, + AlertCircle, + Loader2, + Check, +} from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Progress } from "@/components/ui/progress"; +import { GoogleSigninButton } from "../signin/google-oauth"; export default function SignupPage() { const [serverError, setServerError] = useState(null); @@ -75,7 +84,9 @@ export default function SignupPage() { const data = await registerUser(values.email, values.password); if (!data) { - setServerError("An error occurred while registering. Please try again."); + setServerError( + "An error occurred while registering. Please try again." + ); throw new Error("No data received from the server."); } @@ -120,9 +131,12 @@ export default function SignupPage() {
-

Join the farming revolution

+

+ Join the farming revolution +

- Create your account today and discover how ForFarm can help you optimize your agricultural operations. + Create your account today and discover how ForFarm can help + you optimize your agricultural operations.

{[ @@ -148,11 +162,18 @@ export default function SignupPage() {
{/* Theme Selector Placeholder */} -
Theme Selector Placeholder
+
+ Theme Selector Placeholder +
- +
@@ -160,7 +181,10 @@ export default function SignupPage() {

Create your account

Already have an account?{" "} - + Sign in

@@ -184,7 +208,10 @@ export default function SignupPage() {
{/* Email */}
-