package catalog import ( "context" "errors" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) func (s *Service) ListCategoryAttributes(ctx context.Context, companyID uuid.UUID, categoryUniqueID string) ([]map[string]any, error) { categoryUniqueID = strings.TrimSpace(categoryUniqueID) if categoryUniqueID == "" { return nil, ClientMsg("category_unique_id required") } rows, err := s.Pool.Query(ctx, ` SELECT ca.id, ca.category_unique_id, ca.attribute_id, ca.required, a.attribute_key, a.name, a.value_type FROM category_attributes ca INNER JOIN attributes a ON a.id = ca.attribute_id AND a.company_id = ca.company_id WHERE ca.company_id = $1 AND ca.category_unique_id = $2 ORDER BY a.name`, companyID, categoryUniqueID) if err != nil { return nil, err } defer rows.Close() return scanMaps(rows, []string{"id", "category_unique_id", "attribute_id", "required", "attribute_key", "name", "value_type"}) } func (s *Service) LinkCategoryAttribute(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeID uuid.UUID, required bool) (map[string]any, error) { categoryUniqueID = strings.TrimSpace(categoryUniqueID) if categoryUniqueID == "" { return nil, ClientMsg("category_unique_id required") } var catExists bool if err := s.Pool.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM categories WHERE company_id = $1 AND unique_id = $2)`, companyID, categoryUniqueID).Scan(&catExists); err != nil { return nil, err } if !catExists { return nil, ClientMsg("category not found") } var attrCompany uuid.UUID err := s.Pool.QueryRow(ctx, `SELECT company_id FROM attributes WHERE id = $1`, attributeID).Scan(&attrCompany) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ClientMsg("attribute not found") } return nil, err } if attrCompany != companyID { return nil, ClientMsg("attribute not found") } var id uuid.UUID err = s.Pool.QueryRow(ctx, ` INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required) VALUES ($1, $2, $3, $4) ON CONFLICT (company_id, category_unique_id, attribute_id) DO UPDATE SET required = EXCLUDED.required, updated_at = now() RETURNING id`, companyID, categoryUniqueID, attributeID, required).Scan(&id) if err != nil { return nil, err } row := s.Pool.QueryRow(ctx, ` SELECT ca.id, ca.category_unique_id, ca.attribute_id, ca.required, a.attribute_key, a.name, a.value_type FROM category_attributes ca INNER JOIN attributes a ON a.id = ca.attribute_id WHERE ca.id = $1 AND ca.company_id = $2`, id, companyID) return scanMap(row, []string{"id", "category_unique_id", "attribute_id", "required", "attribute_key", "name", "value_type"}) } func (s *Service) UnlinkCategoryAttribute(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeID uuid.UUID) error { categoryUniqueID = strings.TrimSpace(categoryUniqueID) ct, err := s.Pool.Exec(ctx, ` DELETE FROM category_attributes WHERE company_id = $1 AND category_unique_id = $2 AND attribute_id = $3`, companyID, categoryUniqueID, attributeID) if err != nil { return err } if ct.RowsAffected() == 0 { return ErrNotFound } return nil } func (s *Service) ReplaceCategoryAttributes(ctx context.Context, companyID uuid.UUID, categoryUniqueID string, attributeIDs []uuid.UUID, required map[string]bool) error { categoryUniqueID = strings.TrimSpace(categoryUniqueID) if categoryUniqueID == "" { return ClientMsg("category_unique_id required") } tx, err := s.Pool.Begin(ctx) if err != nil { return err } defer tx.Rollback(ctx) var catExists bool if err := tx.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM categories WHERE company_id = $1 AND unique_id = $2)`, companyID, categoryUniqueID).Scan(&catExists); err != nil { return err } if !catExists { return ClientMsg("category not found") } if _, err := tx.Exec(ctx, ` DELETE FROM category_attributes WHERE company_id = $1 AND category_unique_id = $2`, companyID, categoryUniqueID); err != nil { return err } if len(attributeIDs) == 0 { return tx.Commit(ctx) } if required == nil { required = map[string]bool{} } ownedRows, err := tx.Query(ctx, ` SELECT id FROM attributes WHERE company_id = $1 AND id = ANY($2::uuid[])`, companyID, attributeIDs) if err != nil { return err } owned := make([]uuid.UUID, 0, len(attributeIDs)) for ownedRows.Next() { var id uuid.UUID if err := ownedRows.Scan(&id); err != nil { ownedRows.Close() return err } owned = append(owned, id) } err = ownedRows.Err() ownedRows.Close() if err != nil { return err } if err := validateAttributeIDsOwned(attributeIDs, owned); err != nil { return err } reqs := make([]bool, len(attributeIDs)) for i, aid := range attributeIDs { reqs[i] = required[aid.String()] } if _, err := tx.Exec(ctx, ` INSERT INTO category_attributes (company_id, category_unique_id, attribute_id, required) SELECT $1, $2, u.attribute_id, u.required FROM unnest($3::uuid[], $4::boolean[]) AS u(attribute_id, required)`, companyID, categoryUniqueID, attributeIDs, reqs); err != nil { return err } return tx.Commit(ctx) } // validateAttributeIDsOwned ensures every requested attribute ID is present in the // company-scoped ownership query result. Missing or cross-tenant IDs surface as // "attribute not found" (same message as the former per-row SELECT path). func validateAttributeIDsOwned(attributeIDs, owned []uuid.UUID) error { if len(attributeIDs) == 0 { return nil } set := make(map[uuid.UUID]struct{}, len(owned)) for _, id := range owned { set[id] = struct{}{} } for _, aid := range attributeIDs { if _, ok := set[aid]; !ok { return ClientMsg("attribute not found") } } return nil }