package repository import ( "context" "fmt" "log/slog" "strings" "time" "github.com/forfarm/backend/internal/cache" "github.com/forfarm/backend/internal/domain" "github.com/google/uuid" ) const ( cacheKeyInventoryStatuses = "inventory:statuses" cacheKeyInventoryCategories = "inventory:categories" ) type postgresInventoryRepository struct { conn Connection eventPublisher domain.EventPublisher cache cache.Cache } func NewPostgresInventory(conn Connection, publisher domain.EventPublisher, c cache.Cache) domain.InventoryRepository { return &postgresInventoryRepository{conn: conn, eventPublisher: publisher, cache: c} } func (p *postgresInventoryRepository) fetch(ctx context.Context, query string, args ...interface{}) ([]domain.InventoryItem, error) { rows, err := p.conn.Query(ctx, query, args...) if err != nil { return nil, err } defer rows.Close() var items []domain.InventoryItem for rows.Next() { var i domain.InventoryItem if err := rows.Scan( &i.ID, &i.UserID, &i.Name, &i.CategoryID, &i.Quantity, &i.UnitID, &i.DateAdded, &i.StatusID, &i.CreatedAt, &i.UpdatedAt, ); err != nil { return nil, err } items = append(items, i) } return items, nil } func (p *postgresInventoryRepository) GetByID(ctx context.Context, id, userID string) (domain.InventoryItem, error) { query := ` SELECT i.id, i.user_id, i.name, i.category_id, i.quantity, i.unit_id, i.date_added, i.status_id, i.created_at, i.updated_at, c.name as category_name, s.name as status_name, u.name as unit_name FROM inventory_items i LEFT JOIN inventory_category c ON i.category_id = c.id LEFT JOIN inventory_status s ON i.status_id = s.id LEFT JOIN harvest_units u ON i.unit_id = u.id WHERE i.id = $1 AND i.user_id = $2` rows, err := p.conn.Query(ctx, query, id, userID) if err != nil { return domain.InventoryItem{}, err } defer rows.Close() if !rows.Next() { return domain.InventoryItem{}, domain.ErrNotFound } var item domain.InventoryItem err = rows.Scan( &item.ID, &item.UserID, &item.Name, &item.CategoryID, &item.Quantity, &item.UnitID, &item.DateAdded, &item.StatusID, &item.CreatedAt, &item.UpdatedAt, &item.Category.Name, &item.Status.Name, &item.Unit.Name, ) if err != nil { return domain.InventoryItem{}, err } return item, nil } func (p *postgresInventoryRepository) GetByUserID( ctx context.Context, userID string, filter domain.InventoryFilter, sort domain.InventorySort, ) ([]domain.InventoryItem, error) { var query strings.Builder args := []interface{}{userID} argPos := 2 query.WriteString(` SELECT i.id, i.user_id, i.name, i.category_id, i.quantity, i.unit_id, i.date_added, i.status_id, i.created_at, i.updated_at, c.name as category_name, s.name as status_name, u.name as unit_name FROM inventory_items i LEFT JOIN inventory_category c ON i.category_id = c.id LEFT JOIN inventory_status s ON i.status_id = s.id LEFT JOIN harvest_units u ON i.unit_id = u.id WHERE i.user_id = $1`) if filter.CategoryID != 0 { query.WriteString(fmt.Sprintf(" AND i.category_id = $%d", argPos)) args = append(args, filter.CategoryID) argPos++ } if filter.StatusID != 0 { query.WriteString(fmt.Sprintf(" AND i.status_id = $%d", argPos)) args = append(args, filter.StatusID) argPos++ } if !filter.StartDate.IsZero() { query.WriteString(fmt.Sprintf(" AND i.date_added >= $%d", argPos)) args = append(args, filter.StartDate) argPos++ } if !filter.EndDate.IsZero() { query.WriteString(fmt.Sprintf(" AND i.date_added <= $%d", argPos)) args = append(args, filter.EndDate) argPos++ } if filter.SearchQuery != "" { query.WriteString(fmt.Sprintf(" AND i.name ILIKE $%d", argPos)) args = append(args, "%"+filter.SearchQuery+"%") argPos++ } if sort.Field == "" { sort.Field = "i.date_added" sort.Direction = "desc" } validSortFields := map[string]bool{ "name": true, "quantity": true, "date_added": true, "created_at": true, } if validSortFields[sort.Field] { query.WriteString(fmt.Sprintf(" ORDER BY %s", sort.Field)) if strings.ToLower(sort.Direction) == "desc" { query.WriteString(" DESC") } else { query.WriteString(" ASC") } } rows, err := p.conn.Query(ctx, query.String(), args...) if err != nil { return nil, err } defer rows.Close() var items []domain.InventoryItem for rows.Next() { var item domain.InventoryItem err := rows.Scan( &item.ID, &item.UserID, &item.Name, &item.CategoryID, &item.Quantity, &item.UnitID, &item.DateAdded, &item.StatusID, &item.CreatedAt, &item.UpdatedAt, &item.Category.Name, &item.Status.Name, &item.Unit.Name, ) if err != nil { return nil, err } items = append(items, item) } return items, nil } func (p *postgresInventoryRepository) GetAll(ctx context.Context) ([]domain.InventoryItem, error) { query := ` SELECT i.id, i.user_id, i.name, i.category_id, i.quantity, i.unit_id, i.date_added, i.status_id, i.created_at, i.updated_at, c.name as category_name, s.name as status_name, u.name as unit_name FROM inventory_items i LEFT JOIN inventory_category c ON i.category_id = c.id LEFT JOIN inventory_status s ON i.status_id = s.id LEFT JOIN harvest_units u ON i.unit_id = u.id ORDER BY i.created_at DESC` return p.fetch(ctx, query) } func (p *postgresInventoryRepository) CreateOrUpdate(ctx context.Context, item *domain.InventoryItem) error { now := time.Now() item.UpdatedAt = now isNew := false if item.ID == "" { isNew = true item.CreatedAt = now query := ` INSERT INTO inventory_items (id, user_id, name, category_id, quantity, unit_id, date_added, status_id, created_at, updated_at) VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id` err := p.conn.QueryRow( ctx, query, item.UserID, item.Name, item.CategoryID, item.Quantity, item.UnitID, item.DateAdded, item.StatusID, item.CreatedAt, item.UpdatedAt, ).Scan(&item.ID) if err != nil { // Log error return err } } else { query := ` UPDATE inventory_items SET name = $1, category_id = $2, quantity = $3, unit_id = $4, date_added = $5, status_id = $6, updated_at = $7 WHERE id = $8 AND user_id = $9 RETURNING id` // Ensure RETURNING id exists or handle differently err := p.conn.QueryRow( ctx, query, item.Name, item.CategoryID, item.Quantity, item.UnitID, item.DateAdded, item.StatusID, item.UpdatedAt, item.ID, item.UserID, ).Scan(&item.ID) // Scan to confirm update happened or handle potential ErrNoRows if err != nil { // Log error return err } } // --- Publish Event --- if p.eventPublisher != nil { eventType := "inventory.item.updated" if isNew { eventType = "inventory.item.created" } payload := map[string]interface{}{ "id": item.ID, "userId": item.UserID, // Include user ID for potential farm lookup in projection "name": item.Name, "categoryId": item.CategoryID, "quantity": item.Quantity, "unitId": item.UnitID, "statusId": item.StatusID, "dateAdded": item.DateAdded, "updatedAt": item.UpdatedAt, // NO farm_id easily available here without extra lookup } event := domain.Event{ ID: uuid.NewString(), Type: eventType, Source: "inventory-repository", Timestamp: time.Now().UTC(), AggregateID: item.UserID, // Use UserID as AggregateID for inventory? Or item.ID? Let's use item.ID. Payload: payload, } // Use AggregateID = item.ID for consistency if item is the aggregate root event.AggregateID = item.ID go func() { bgCtx := context.Background() if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil { fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) // Use proper logging } }() } // --- End Publish Event --- return nil } func (p *postgresInventoryRepository) Delete(ctx context.Context, id, userID string) error { query := `DELETE FROM inventory_items WHERE id = $1 AND user_id = $2` cmdTag, err := p.conn.Exec(ctx, query, id, userID) if err != nil { // Log error return err } if cmdTag.RowsAffected() == 0 { return domain.ErrNotFound // Or a permission error if user doesn't match } // --- Publish Event --- if p.eventPublisher != nil { eventType := "inventory.item.deleted" payload := map[string]interface{}{ "item_id": id, "user_id": userID, // Include user ID } event := domain.Event{ ID: uuid.NewString(), Type: eventType, Source: "inventory-repository", Timestamp: time.Now().UTC(), AggregateID: id, // Use item ID as aggregate ID Payload: payload, } go func() { bgCtx := context.Background() if errPub := p.eventPublisher.Publish(bgCtx, event); errPub != nil { fmt.Printf("Error publishing %s event: %v\n", eventType, errPub) // Use proper logging } }() } // --- End Publish Event --- return nil } func (p *postgresInventoryRepository) GetStatuses(ctx context.Context) ([]domain.InventoryStatus, error) { if cached, found := p.cache.Get(cacheKeyInventoryStatuses); found { if statuses, ok := cached.([]domain.InventoryStatus); ok { slog.DebugContext(ctx, "Cache hit for GetInventoryStatuses", "key", cacheKeyInventoryStatuses) return statuses, nil } } slog.DebugContext(ctx, "Cache miss for GetInventoryStatuses", "key", cacheKeyInventoryStatuses) query := `SELECT id, name FROM inventory_status ORDER BY id` rows, err := p.conn.Query(ctx, query) if err != nil { return nil, err } defer rows.Close() var statuses []domain.InventoryStatus for rows.Next() { var s domain.InventoryStatus if err := rows.Scan(&s.ID, &s.Name); err != nil { return nil, err } statuses = append(statuses, s) } if err := rows.Err(); err != nil { return nil, err } if len(statuses) > 0 { p.cache.Set(cacheKeyInventoryStatuses, statuses, cacheTTLStatic) } return statuses, nil } func (p *postgresInventoryRepository) GetCategories(ctx context.Context) ([]domain.InventoryCategory, error) { if cached, found := p.cache.Get(cacheKeyInventoryCategories); found { if categories, ok := cached.([]domain.InventoryCategory); ok { slog.DebugContext(ctx, "Cache hit for GetInventoryCategories", "key", cacheKeyInventoryCategories) return categories, nil } } slog.DebugContext(ctx, "Cache miss for GetInventoryCategories", "key", cacheKeyInventoryCategories) query := `SELECT id, name FROM inventory_category ORDER BY id` rows, err := p.conn.Query(ctx, query) if err != nil { return nil, err } defer rows.Close() var categories []domain.InventoryCategory for rows.Next() { var c domain.InventoryCategory if err := rows.Scan(&c.ID, &c.Name); err != nil { return nil, err } categories = append(categories, c) } if err := rows.Err(); err != nil { return nil, err } if len(categories) > 0 { p.cache.Set(cacheKeyInventoryCategories, categories, cacheTTLStatic) } return categories, nil }